mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
perf(glass): skip unused shader samples and gate paused rendering
This commit is contained in:
@@ -92,6 +92,27 @@ class ResizeObserverMock {
|
||||
}
|
||||
}
|
||||
|
||||
/** 提供可显式触发 DOM 变更回调的 MutationObserver 实现。 */
|
||||
class MutationObserverTriggerMock implements MutationObserver {
|
||||
static instances: MutationObserverTriggerMock[] = []
|
||||
|
||||
constructor(private readonly callback: MutationCallback) {
|
||||
MutationObserverTriggerMock.instances.push(this)
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
|
||||
observe() {}
|
||||
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
|
||||
trigger(records: MutationRecord[]) {
|
||||
this.callback(records, this)
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建带稳定视口边界的光学表面元素。 */
|
||||
function appendOpticalSurface(className: string, bounds: Pick<DOMRect, 'height' | 'width' | 'x' | 'y'>) {
|
||||
const surface = document.createElement('section')
|
||||
@@ -170,6 +191,30 @@ function createRemovalRecord(target: Element, removedNodes: Element[]): Mutation
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造会改变光学表面资格的属性变更记录。 */
|
||||
function createAttributeRecord(target: Element): MutationRecord {
|
||||
return {
|
||||
addedNodes: Object.assign([], { item: () => null }) as unknown as NodeList,
|
||||
attributeName: 'data-glass-optical-mode',
|
||||
attributeNamespace: null,
|
||||
nextSibling: null,
|
||||
oldValue: null,
|
||||
previousSibling: null,
|
||||
removedNodes: Object.assign([], { item: () => null }) as unknown as NodeList,
|
||||
target,
|
||||
type: 'attributes',
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行有限数量的排队帧,避免交互衰减帧让测试进入无界循环。 */
|
||||
function flushQueuedAnimationFrames(callbacks: Map<number, FrameRequestCallback>, passes = 8) {
|
||||
for (let pass = 0; pass < passes && callbacks.size > 0; pass += 1) {
|
||||
const queued = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
queued.forEach(callback => callback(performance.now() + pass * 16))
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchTouchEvent(
|
||||
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
|
||||
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
|
||||
@@ -185,8 +230,12 @@ function dispatchTouchEvent(
|
||||
return event
|
||||
}
|
||||
|
||||
let documentHasFocus = true
|
||||
|
||||
beforeEach(() => {
|
||||
ResizeObserverMock.instances = []
|
||||
MutationObserverTriggerMock.instances = []
|
||||
documentHasFocus = true
|
||||
wallpaperToneMocks.load.mockReset()
|
||||
wallpaperToneMocks.load.mockResolvedValue({
|
||||
corsReady: false,
|
||||
@@ -200,6 +249,7 @@ beforeEach(() => {
|
||||
wallpaperToneMocks.takeDecodedSource.mockReturnValue(undefined)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
vi.stubGlobal('WebGLRenderingContext', class {})
|
||||
vi.spyOn(document, 'hasFocus').mockImplementation(() => documentHasFocus)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
|
||||
})
|
||||
|
||||
@@ -1344,6 +1394,48 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps stable texture sampling lazy and limits diffuse taps to non-prefiltered frosted output', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
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'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uTextureMix)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const material = scene.children[0].material!
|
||||
const shader = material.fragmentShader
|
||||
const previousToneIndex = shader.indexOf('toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure)')
|
||||
const currentToneIndex = shader.indexOf('toneMapWallpaper(current, viewportUv, uWallpaperExposure)')
|
||||
const mixIndex = shader.indexOf('return mix(previousTone, currentTone, uTextureMix)')
|
||||
const diffuseStart = shader.indexOf('if (frosted > 0.5 && usesPrefilteredFrost <= 0.5)')
|
||||
|
||||
expect(shader).toContain('bool needsPrevious = uTextureMix < 0.999')
|
||||
expect(shader).toContain('bool needsCurrent = uTextureMix > 0.001')
|
||||
expect(shader).toContain('if (needsPrevious)')
|
||||
expect(shader).toContain('if (needsCurrent)')
|
||||
expect(previousToneIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(currentToneIndex).toBeGreaterThan(previousToneIndex)
|
||||
expect(mixIndex).toBeGreaterThan(currentToneIndex)
|
||||
expect(diffuseStart).toBeGreaterThanOrEqual(0)
|
||||
expect(shader.indexOf('sampleHighQualityDiffuse', diffuseStart)).toBeGreaterThan(diffuseStart)
|
||||
expect(shader.indexOf('sampleBalancedDiffuse', diffuseStart)).toBeGreaterThan(diffuseStart)
|
||||
expect(material.uniforms.uQuality.value).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('retains a prepared transaction until it becomes the rendered active wallpaper', async () => {
|
||||
const three = await import('three')
|
||||
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg')
|
||||
@@ -2768,6 +2860,7 @@ describe('glass optical surface discovery', () => {
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -2775,12 +2868,46 @@ describe('glass optical surface discovery', () => {
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1)
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
|
||||
visibilityState = 'visible'
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(render).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps an initially unfocused visible renderer ready without drawing until focus resumes it', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
documentHasFocus = false
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
|
||||
documentHasFocus = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(render).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
@@ -2904,6 +3031,413 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it.each(['blur', 'hidden'] as const)(
|
||||
'blocks render, interaction, geometry, and scroll work after a real %s pause',
|
||||
async pauseEvent => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
const root = document.createElement('div')
|
||||
root.append(canvas)
|
||||
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
document.body.append(root)
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
vi.stubGlobal('MutationObserver', MutationObserverTriggerMock)
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const reflectionStrength = ref(40)
|
||||
const motionRevision = ref(0)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('fluid'),
|
||||
pageMotion: {
|
||||
acknowledgeGeometryReady: vi.fn(() => true),
|
||||
active: ref(false),
|
||||
epoch: ref(1),
|
||||
opacity: ref(1),
|
||||
revision: motionRevision,
|
||||
},
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
reflectionStrength,
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
flushQueuedAnimationFrames(callbacks)
|
||||
render.mockClear()
|
||||
callbacks.clear()
|
||||
const framesBeforePause = renderer?.renderedFrames.value
|
||||
|
||||
if (pauseEvent === 'blur') {
|
||||
documentHasFocus = false
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
} else {
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
setOpticalSurfaceBounds(surface, { height: 360, width: 560, x: 220, y: 180 })
|
||||
reflectionStrength.value = 100
|
||||
motionRevision.value += 1
|
||||
ResizeObserverMock.instances.forEach(observer => observer.trigger())
|
||||
MutationObserverTriggerMock.instances[0]?.trigger([createAttributeRecord(surface)])
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 280, clientY: 240 }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
expect(renderer?.renderedFrames.value).toBe(framesBeforePause)
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
},
|
||||
)
|
||||
|
||||
it('restores the latest uniform and scroll geometry after a paused resume', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const root = document.createElement('div')
|
||||
root.append(canvas)
|
||||
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
Object.defineProperty(root, 'scrollWidth', { configurable: true, value: 1200 })
|
||||
document.body.append(root)
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const reflectionStrength = ref(40)
|
||||
const motionRevision = ref(0)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'),
|
||||
pageMotion: {
|
||||
acknowledgeGeometryReady: vi.fn(() => true),
|
||||
active: ref(false),
|
||||
epoch: ref(1),
|
||||
opacity: ref(1),
|
||||
revision: motionRevision,
|
||||
},
|
||||
quality: ref('balanced'),
|
||||
reflectionStrength,
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
flushQueuedAnimationFrames(callbacks)
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uRects)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = scene.children[0].material!.uniforms
|
||||
render.mockClear()
|
||||
callbacks.clear()
|
||||
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
setOpticalSurfaceBounds(surface, { height: 360, width: 560, x: 220, y: 180 })
|
||||
reflectionStrength.value = 100
|
||||
motionRevision.value += 1
|
||||
ResizeObserverMock.instances.forEach(observer => observer.trigger())
|
||||
await nextTick()
|
||||
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(render).toHaveBeenCalledTimes(1)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
|
||||
expect(uniforms.uRects.value[0].x).toBeCloseTo(220 / 1200)
|
||||
expect(uniforms.uRects.value[0].y).toBeCloseTo(1 - (180 + 360) / 1200)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('cancels a pending resume when blur arrives before nextTick and resumes on the next focus', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
render.mockClear()
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
documentHasFocus = false
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
|
||||
documentHasFocus = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(render).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps hidden-to-visible rendering paused without focus until an explicit focus event', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
render.mockClear()
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
documentHasFocus = false
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
|
||||
documentHasFocus = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
expect(render).toHaveBeenCalledTimes(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('commits correct native-scroll pixels before releasing the takeover on resume', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const root = document.createElement('div')
|
||||
root.append(canvas)
|
||||
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
document.body.append(root)
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
let scrollY = 0
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => scrollY)
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const snapshots: Array<{ hasWallpaper: number; nativePresentation: string | undefined; scrollOffset: number }> = []
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render').mockImplementation(scene => {
|
||||
const material = (scene as unknown as { children: Array<{ material?: ShaderMaterial }> }).children[0]?.material
|
||||
const uniforms = material?.uniforms as
|
||||
{ uHasWallpaperTexture?: { value: number }; uScrollOffset?: { value: { y: number } } } | undefined
|
||||
if (uniforms?.uHasWallpaperTexture && uniforms.uScrollOffset) {
|
||||
snapshots.push({
|
||||
hasWallpaper: uniforms.uHasWallpaperTexture.value,
|
||||
nativePresentation: document.documentElement.dataset.glassScrollPresentation,
|
||||
scrollOffset: uniforms.uScrollOffset.value.y,
|
||||
})
|
||||
}
|
||||
})
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'),
|
||||
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'))
|
||||
flushQueuedAnimationFrames(callbacks)
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: { uniforms: { uHasWallpaperTexture: { value: number }; uScrollOffset: { value: { y: number } } } }
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
snapshots.length = 0
|
||||
render.mockClear()
|
||||
|
||||
scrollY = 240
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||
expect(uniforms.uHasWallpaperTexture.value).toBe(0)
|
||||
const firstScrollFrame = [...callbacks.values()][0]
|
||||
callbacks.clear()
|
||||
firstScrollFrame?.(performance.now() + 16)
|
||||
expect(uniforms.uScrollOffset.value.y).toBe(240)
|
||||
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(callbacks.size).toBe(0)
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||
expect(uniforms.uHasWallpaperTexture.value).toBe(0)
|
||||
|
||||
scrollY = 480
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
expect(uniforms.uScrollOffset.value.y).toBe(240)
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(snapshots).toEqual([{ hasWallpaper: 1, nativePresentation: 'native', scrollOffset: 480 }])
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBeUndefined()
|
||||
expect(uniforms.uHasWallpaperTexture.value).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('waits for page-motion geometry acknowledgement after a paused resume', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const root = document.createElement('div')
|
||||
root.append(canvas)
|
||||
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
document.body.append(root)
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const acknowledgeGeometryReady = vi.fn(() => true)
|
||||
const motionActive = ref(false)
|
||||
const motionEpoch = ref(0)
|
||||
const motionRevision = ref(0)
|
||||
const committedWeights: number[] = []
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render').mockImplementation(scene => {
|
||||
const material = (scene as unknown as { children: Array<{ material?: ShaderMaterial }> }).children[0]?.material
|
||||
const weights = material?.uniforms.uSurfaceWeights?.value as number[] | undefined
|
||||
if (weights) committedWeights.push(weights[0])
|
||||
})
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'),
|
||||
pageMotion: {
|
||||
acknowledgeGeometryReady,
|
||||
active: motionActive,
|
||||
epoch: motionEpoch,
|
||||
opacity: ref(1),
|
||||
revision: motionRevision,
|
||||
},
|
||||
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'))
|
||||
flushQueuedAnimationFrames(callbacks)
|
||||
committedWeights.length = 0
|
||||
render.mockClear()
|
||||
callbacks.clear()
|
||||
|
||||
motionEpoch.value = 7
|
||||
motionActive.value = true
|
||||
expect(callbacks.size).toBe(1)
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(callbacks.size).toBe(0)
|
||||
expect(acknowledgeGeometryReady).not.toHaveBeenCalled()
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(acknowledgeGeometryReady).not.toHaveBeenCalled()
|
||||
expect(committedWeights[0]).toBe(0)
|
||||
flushQueuedAnimationFrames(callbacks, 6)
|
||||
|
||||
expect(acknowledgeGeometryReady).toHaveBeenCalledOnce()
|
||||
expect(acknowledgeGeometryReady).toHaveBeenCalledWith(7, expect.any(Number))
|
||||
expect(committedWeights.at(-1)).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('releases renderer resources after the long background timeout', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -2945,11 +3479,100 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('rebuilds long-background resources and acknowledges the latest page-motion epoch', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const root = document.createElement('div')
|
||||
root.append(canvas)
|
||||
Object.defineProperty(root, 'scrollHeight', { configurable: true, value: 1200 })
|
||||
document.body.append(root)
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const acknowledgeGeometryReady = vi.fn()
|
||||
const committedWeights: number[] = []
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render').mockImplementation(scene => {
|
||||
const material = (scene as unknown as { children: Array<{ material?: ShaderMaterial }> }).children[0]?.material
|
||||
const weights = material?.uniforms.uSurfaceWeights?.value as number[] | undefined
|
||||
if (weights) committedWeights.push(weights[0])
|
||||
})
|
||||
const motionActive = ref(false)
|
||||
const motionEpoch = ref(0)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'),
|
||||
pageMotion: {
|
||||
acknowledgeGeometryReady,
|
||||
active: motionActive,
|
||||
epoch: motionEpoch,
|
||||
opacity: ref(1),
|
||||
revision: ref(0),
|
||||
},
|
||||
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'))
|
||||
flushQueuedAnimationFrames(callbacks)
|
||||
committedWeights.length = 0
|
||||
render.mockClear()
|
||||
callbacks.clear()
|
||||
|
||||
motionEpoch.value = 11
|
||||
motionActive.value = true
|
||||
motionEpoch.value = 12
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(contextLoss).not.toHaveBeenCalled()
|
||||
expect(acknowledgeGeometryReady).not.toHaveBeenCalled()
|
||||
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(acknowledgeGeometryReady).not.toHaveBeenCalled()
|
||||
expect(callbacks.size).toBeGreaterThan(0)
|
||||
flushQueuedAnimationFrames(callbacks, 8)
|
||||
|
||||
expect(acknowledgeGeometryReady).toHaveBeenCalledOnce()
|
||||
expect(acknowledgeGeometryReady).toHaveBeenCalledWith(12, expect.any(Number))
|
||||
expect(committedWeights).toContain(0)
|
||||
expect(committedWeights.at(-1)).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('releases a paused renderer after the visible window remains unfocused', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(false)
|
||||
documentHasFocus = false
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
|
||||
@@ -707,27 +707,34 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
|
||||
vec3 sampleWallpaper(vec2 uv) {
|
||||
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
|
||||
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
|
||||
vec3 previous;
|
||||
vec3 current;
|
||||
vec3 previous = vec3(0.0);
|
||||
vec3 current = vec3(0.0);
|
||||
// 稳定端点只读取参与输出的纹理;过渡中仍按各自曝光映射后混合。
|
||||
bool needsPrevious = uTextureMix < 0.999;
|
||||
bool needsCurrent = uTextureMix > 0.001;
|
||||
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
|
||||
float frostLod = (1.0 - uFrostDetailLevel) * 6.0;
|
||||
// 低分辨率预滤已经扩大了每个 texel 的原图 footprint,LOD 只追加当前纹理内的低通层级。
|
||||
float frostGradientScale = exp2(frostLod);
|
||||
if (needsPrevious) {
|
||||
previous = texture2DGradEXT(
|
||||
uPreviousFrostedTexture,
|
||||
previousUv,
|
||||
dFdx(previousUv) * frostGradientScale,
|
||||
dFdy(previousUv) * frostGradientScale
|
||||
).rgb;
|
||||
}
|
||||
if (needsCurrent) {
|
||||
current = texture2DGradEXT(
|
||||
uFrostedTexture,
|
||||
uv,
|
||||
dFdx(uv) * frostGradientScale,
|
||||
dFdy(uv) * frostGradientScale
|
||||
).rgb;
|
||||
}
|
||||
} else {
|
||||
previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
current = texture2D(uTexture, uv).rgb;
|
||||
if (needsPrevious) previous = texture2D(uPreviousTexture, previousUv).rgb;
|
||||
if (needsCurrent) current = texture2D(uTexture, uv).rgb;
|
||||
}
|
||||
|
||||
if (uTextureMix <= 0.001) {
|
||||
@@ -929,6 +936,8 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
? refracted
|
||||
: sampleChromatic(sourceUv, detailSeparation);
|
||||
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
|
||||
// 非磨砂不使用扩散结果;预滤磨砂已具备低通纹理,两者都无需额外邻域采样。
|
||||
if (frosted > 0.5 && usesPrefilteredFrost <= 0.5) {
|
||||
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
|
||||
float diffusionRadius =
|
||||
mix(0.0022, 0.0038, uQuality) *
|
||||
@@ -939,15 +948,11 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
);
|
||||
float frostedDensity = frosted * (1.0 - uFrostDetailLevel);
|
||||
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
|
||||
vec3 diffused;
|
||||
if (usesPrefilteredFrost > 0.5) {
|
||||
diffused = refracted;
|
||||
} else if (uQuality > 0.5) {
|
||||
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
} else {
|
||||
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
}
|
||||
vec3 diffused = uQuality > 0.5
|
||||
? sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius)
|
||||
: sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
|
||||
refracted = mix(refracted, diffused, frosted);
|
||||
}
|
||||
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
|
||||
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
|
||||
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
|
||||
@@ -1326,12 +1331,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let contextRecoveryPending = false
|
||||
let resumePromise: Promise<void> | null = null
|
||||
let resumeVersion = 0
|
||||
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
|
||||
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
|
||||
let dynamicsGeneration = 0
|
||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||
const usesDynamicsOnly = () =>
|
||||
presentationSpace === 'scroll' || (presentationSpace === 'fixed' && toValue(options.appearance) === 'frosted')
|
||||
const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache()
|
||||
|
||||
/** 所有持续绘制入口共享活动边界,资源准备和 uniform 同步不依赖呈现帧。 */
|
||||
function canPresentFrame() {
|
||||
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
|
||||
}
|
||||
|
||||
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
||||
function syncWallpaperSamplingMode() {
|
||||
if (!resources) return
|
||||
@@ -1347,18 +1359,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scrollPresentationRestoreTimer = null
|
||||
}
|
||||
|
||||
function finishNativeScrollPresentation(timestamp = performance.now()) {
|
||||
function finishNativeScrollPresentation(timestamp = performance.now(), advanceFlow = false) {
|
||||
clearScrollPresentationRestoreTimer()
|
||||
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed) return
|
||||
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed || !canPresentFrame()) return
|
||||
|
||||
scrollWallpaperSamplingSuppressed = false
|
||||
syncWallpaperSamplingMode()
|
||||
renderFrame(timestamp, false)
|
||||
renderFrame(timestamp, advanceFlow)
|
||||
document.documentElement.removeAttribute('data-glass-scroll-presentation')
|
||||
}
|
||||
|
||||
function beginNativeScrollPresentation() {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
clearScrollPresentationRestoreTimer()
|
||||
scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180)
|
||||
@@ -1532,6 +1544,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
transformingSurfaces.clear()
|
||||
}
|
||||
|
||||
/** 暂停和销毁共用几何帧清理,恢复时按最新 DOM 重新测量。 */
|
||||
function cancelSurfaceUpdateFrames() {
|
||||
if (surfaceUpdateFrame !== null) cancelAnimationFrame(surfaceUpdateFrame)
|
||||
surfaceUpdateFrame = null
|
||||
if (surfaceStabilityFrame !== null) cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
}
|
||||
|
||||
function clearBackgroundDisposeTimer() {
|
||||
if (backgroundDisposeTimer === null) return
|
||||
|
||||
@@ -1584,7 +1608,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
function renderWallpaperTransitionFrame(timestamp: number) {
|
||||
wallpaperTransitionFrame = null
|
||||
if (document.visibilityState === 'hidden') return
|
||||
if (!canPresentFrame()) return
|
||||
|
||||
renderFrame(timestamp)
|
||||
if (previousTexture && wallpaperTransitionFrame === null) {
|
||||
@@ -1593,7 +1617,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleWallpaperTransition() {
|
||||
if (wallpaperTransitionFrame !== null || !previousTexture || document.visibilityState === 'hidden') return
|
||||
if (wallpaperTransitionFrame !== null || !previousTexture || !canPresentFrame()) return
|
||||
|
||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
||||
}
|
||||
@@ -1821,7 +1845,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
updateWallpaperTransition(timestamp)
|
||||
if (fluidDynamics && advanceFlow) {
|
||||
@@ -1859,7 +1883,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
if (animationFrame !== null || !resources) return
|
||||
if (animationFrame !== null || !resources || !canPresentFrame()) return
|
||||
|
||||
animationFrame = requestAnimationFrame(renderScheduledFrame)
|
||||
}
|
||||
@@ -2141,11 +2165,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleSurfaceUpdate() {
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceUpdateFrame !== null || !resources) return
|
||||
|
||||
surfaceUpdateFrame = requestAnimationFrame(timestamp => {
|
||||
surfaceUpdateFrame = null
|
||||
if (!canPresentFrame()) return
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
// 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。
|
||||
renderFrame(timestamp, false)
|
||||
@@ -2155,6 +2181,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
|
||||
function scheduleSurfaceStabilityUpdate(motionEpoch?: number) {
|
||||
if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(true)) return
|
||||
surfaceStabilityPass = 0
|
||||
surfaceStableFrameCount = 0
|
||||
@@ -2163,7 +2190,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
const sample = (timestamp: number) => {
|
||||
surfaceStabilityFrame = null
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
const signature = surfaceSlots
|
||||
@@ -2203,13 +2230,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
|
||||
*/
|
||||
function schedulePresentationResizeUpdate() {
|
||||
if (!canPresentFrame()) return
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
|
||||
const sample = () => {
|
||||
presentationResizeTimer = null
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const presentation = measurePresentationSize()
|
||||
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
|
||||
@@ -2231,7 +2259,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
|
||||
function commitActivePagePresentation(timestamp = performance.now()) {
|
||||
if (!resources || presentationSpace !== 'scroll' || !toValue(options.pageMotion?.active ?? false)) return false
|
||||
if (
|
||||
!resources ||
|
||||
!canPresentFrame() ||
|
||||
presentationSpace !== 'scroll' ||
|
||||
!toValue(options.pageMotion?.active ?? false)
|
||||
)
|
||||
return false
|
||||
|
||||
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
@@ -2246,7 +2280,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
|
||||
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
|
||||
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
|
||||
@@ -2262,12 +2296,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
|
||||
function scheduleSurfaceTransformFrame() {
|
||||
if (!canPresentFrame()) return
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceTransformFrame !== null || !resources) return
|
||||
|
||||
surfaceTransformFrame = requestAnimationFrame(timestamp => {
|
||||
surfaceTransformFrame = null
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
|
||||
if (!resources || !canPresentFrame()) {
|
||||
cancelSurfaceTransformFrame()
|
||||
return
|
||||
}
|
||||
@@ -2455,7 +2490,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function resizeRenderer() {
|
||||
if (!resources) return
|
||||
if (!resources || !canPresentFrame()) return
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
@@ -2660,7 +2695,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function renderInteractionFrame(timestamp: number) {
|
||||
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
|
||||
if (!resources || !canPresentFrame()) {
|
||||
animationFrame = null
|
||||
interactionAnimating = false
|
||||
return
|
||||
@@ -2725,7 +2760,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function startInteractionAnimation() {
|
||||
if (interactionAnimating) return
|
||||
if (interactionAnimating || !canPresentFrame()) return
|
||||
|
||||
cancelScheduledFrame()
|
||||
interactionAnimating = true
|
||||
@@ -2742,6 +2777,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
target?: EventTarget | null,
|
||||
) {
|
||||
if (
|
||||
!canPresentFrame() ||
|
||||
!hasDynamicCapability() ||
|
||||
(hasRippleCapability() && presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed)
|
||||
) {
|
||||
@@ -2940,12 +2976,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scrollAnimationFrame = null
|
||||
scrollFrameCommitted = false
|
||||
scrollLateGeometryCommitted = false
|
||||
if (
|
||||
presentationSpace !== 'scroll' ||
|
||||
!resources ||
|
||||
!toValue(options.active) ||
|
||||
document.visibilityState === 'hidden'
|
||||
) {
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) {
|
||||
scrollDirty = false
|
||||
scrollStableFrameCount = 0
|
||||
return
|
||||
@@ -2991,13 +3022,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function scheduleScrollFrame() {
|
||||
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources) return
|
||||
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
|
||||
}
|
||||
|
||||
function handleScroll(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
const target = event.target
|
||||
if (!isRelevantScrollTarget(target)) return
|
||||
@@ -3034,7 +3065,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function handleScrollEnd(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
|
||||
|
||||
const target = event.target
|
||||
if (
|
||||
@@ -3052,20 +3083,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */
|
||||
function pauseRenderer() {
|
||||
presentationPaused = true
|
||||
resumeVersion += 1
|
||||
resumePromise = null
|
||||
cancelScheduledFrame()
|
||||
cancelScrollFrame()
|
||||
finishNativeScrollPresentation()
|
||||
// 原生滚动背板保持接管,恢复时先提交正确像素再揭示 canvas。
|
||||
cancelWallpaperTransitionFrame()
|
||||
cancelSurfaceTransformFrame()
|
||||
cancelSurfaceUpdateFrames()
|
||||
interactionAnimating = false
|
||||
}
|
||||
|
||||
/** 合并同一可见性事务的多个浏览器事件,只恢复一次稳定帧。 */
|
||||
function resumeRenderer() {
|
||||
if (!canPresentFrame()) return Promise.resolve()
|
||||
if (resumePromise) return resumePromise
|
||||
|
||||
const version = resumeVersion
|
||||
const canResume = () => toValue(options.active) && document.visibilityState !== 'hidden'
|
||||
const canResume = () => canPresentFrame()
|
||||
const task = (async () => {
|
||||
clearBackgroundDisposeTimer()
|
||||
if (!canResume()) return
|
||||
@@ -3074,6 +3110,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (version !== resumeVersion || !canResume()) return
|
||||
if (!resources) {
|
||||
await initializeRenderer()
|
||||
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3082,12 +3119,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const timestamp = performance.now()
|
||||
const keepRippleAnimating = hasRippleCapability() ? advanceRipple(timestamp) : false
|
||||
if (!keepRippleAnimating) resetInteractionState()
|
||||
renderFrame(timestamp, !hasRippleCapability())
|
||||
if (scrollWallpaperSamplingSuppressed) finishNativeScrollPresentation(timestamp, !hasRippleCapability())
|
||||
else renderFrame(timestamp, !hasRippleCapability())
|
||||
if (keepRippleAnimating) {
|
||||
interactionAnimating = true
|
||||
animationFrame = requestAnimationFrame(renderInteractionFrame)
|
||||
}
|
||||
scheduleWallpaperTransition()
|
||||
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
})()
|
||||
|
||||
resumePromise = task
|
||||
@@ -3111,12 +3150,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (document.visibilityState === 'hidden' || !document.hasFocus()) {
|
||||
pauseRenderer()
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden')
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
|
||||
return
|
||||
}
|
||||
|
||||
presentationPaused = false
|
||||
void resumeRenderer()
|
||||
}
|
||||
|
||||
@@ -3127,8 +3167,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus())
|
||||
}
|
||||
|
||||
function handleWindowResume() {
|
||||
if (document.visibilityState === 'visible') void resumeRenderer()
|
||||
function handleWindowResume(event: Event) {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (event.type === 'pageshow' && !document.hasFocus()) {
|
||||
handleWindowBlur()
|
||||
return
|
||||
}
|
||||
|
||||
presentationPaused = false
|
||||
void resumeRenderer()
|
||||
}
|
||||
|
||||
function handleContextLost(event: Event) {
|
||||
@@ -3350,21 +3397,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
cancelScrollFrame()
|
||||
cancelWallpaperTransitionFrame()
|
||||
cancelSurfaceTransformFrame()
|
||||
cancelSurfaceUpdateFrames()
|
||||
clearBackgroundDisposeTimer()
|
||||
if (surfaceUpdateFrame !== null) {
|
||||
cancelAnimationFrame(surfaceUpdateFrame)
|
||||
surfaceUpdateFrame = null
|
||||
}
|
||||
if (surfaceStabilityFrame !== null) {
|
||||
cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
}
|
||||
if (presentationResizeTimer !== null) {
|
||||
window.clearTimeout(presentationResizeTimer)
|
||||
presentationResizeTimer = null
|
||||
}
|
||||
presentationResizeCandidate = ''
|
||||
presentationResizeStableSamples = 0
|
||||
removeEvents()
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
@@ -3994,6 +4028,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
resizeRenderer()
|
||||
await loadWallpaper(toValue(options.wallpaperUrl), version)
|
||||
preparePendingWallpaper()
|
||||
if (version === loadVersion && presentationPaused) {
|
||||
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
|
||||
}
|
||||
} catch (error) {
|
||||
fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user