perf(glass): skip unused shader samples and gate paused rendering

This commit is contained in:
InfinityPacer
2026-09-06 17:54:05 +08:00
parent d39b240bc8
commit e6c4f21659
2 changed files with 741 additions and 81 deletions
@@ -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'>) { function appendOpticalSurface(className: string, bounds: Pick<DOMRect, 'height' | 'width' | 'x' | 'y'>) {
const surface = document.createElement('section') 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( function dispatchTouchEvent(
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart', type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
touches: Array<{ clientX: number; clientY: number; identifier: number }>, touches: Array<{ clientX: number; clientY: number; identifier: number }>,
@@ -185,8 +230,12 @@ function dispatchTouchEvent(
return event return event
} }
let documentHasFocus = true
beforeEach(() => { beforeEach(() => {
ResizeObserverMock.instances = [] ResizeObserverMock.instances = []
MutationObserverTriggerMock.instances = []
documentHasFocus = true
wallpaperToneMocks.load.mockReset() wallpaperToneMocks.load.mockReset()
wallpaperToneMocks.load.mockResolvedValue({ wallpaperToneMocks.load.mockResolvedValue({
corsReady: false, corsReady: false,
@@ -200,6 +249,7 @@ beforeEach(() => {
wallpaperToneMocks.takeDecodedSource.mockReturnValue(undefined) wallpaperToneMocks.takeDecodedSource.mockReturnValue(undefined)
vi.stubGlobal('ResizeObserver', ResizeObserverMock) vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('WebGLRenderingContext', class {}) vi.stubGlobal('WebGLRenderingContext', class {})
vi.spyOn(document, 'hasFocus').mockImplementation(() => documentHasFocus)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
}) })
@@ -1344,6 +1394,48 @@ describe('glass optical surface discovery', () => {
scope.stop() 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 () => { it('retains a prepared transaction until it becomes the rendered active wallpaper', async () => {
const three = await import('three') const three = await import('three')
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg') 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')) await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
vi.useFakeTimers() vi.useFakeTimers()
@@ -2775,12 +2868,46 @@ describe('glass optical surface discovery', () => {
document.dispatchEvent(new Event('visibilitychange')) document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1) await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1)
expect(dispose).not.toHaveBeenCalled() expect(dispose).not.toHaveBeenCalled()
expect(contextLoss).not.toHaveBeenCalled()
visibilityState = 'visible' visibilityState = 'visible'
window.dispatchEvent(new Event('focus')) window.dispatchEvent(new Event('focus'))
await nextTick() await nextTick()
await Promise.resolve() await Promise.resolve()
expect(dispose).not.toHaveBeenCalled() 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() expect(render).toHaveBeenCalled()
scope.stop() scope.stop()
}) })
@@ -2904,6 +3031,413 @@ describe('glass optical surface discovery', () => {
scope.stop() 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 () => { it('releases renderer resources after the long background timeout', async () => {
const three = await import('three') const three = await import('three')
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
@@ -2945,11 +3479,100 @@ describe('glass optical surface discovery', () => {
scope.stop() 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 () => { it('releases a paused renderer after the visible window remains unfocused', async () => {
const three = await import('three') const three = await import('three')
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
vi.spyOn(document, 'hasFocus').mockReturnValue(false) documentHasFocus = false
const scope = effectScope() const scope = effectScope()
const renderer = scope.run(() => const renderer = scope.run(() =>
useGlassOpticalRenderer({ useGlassOpticalRenderer({
+117 -80
View File
@@ -707,27 +707,34 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
vec3 sampleWallpaper(vec2 uv) { vec3 sampleWallpaper(vec2 uv) {
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001)); vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale; vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
vec3 previous; vec3 previous = vec3(0.0);
vec3 current; vec3 current = vec3(0.0);
// 稳定端点只读取参与输出的纹理;过渡中仍按各自曝光映射后混合。
bool needsPrevious = uTextureMix < 0.999;
bool needsCurrent = uTextureMix > 0.001;
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) { if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
float frostLod = (1.0 - uFrostDetailLevel) * 6.0; float frostLod = (1.0 - uFrostDetailLevel) * 6.0;
// 低分辨率预滤已经扩大了每个 texel 的原图 footprint,LOD 只追加当前纹理内的低通层级。 // 低分辨率预滤已经扩大了每个 texel 的原图 footprint,LOD 只追加当前纹理内的低通层级。
float frostGradientScale = exp2(frostLod); float frostGradientScale = exp2(frostLod);
previous = texture2DGradEXT( if (needsPrevious) {
uPreviousFrostedTexture, previous = texture2DGradEXT(
previousUv, uPreviousFrostedTexture,
dFdx(previousUv) * frostGradientScale, previousUv,
dFdy(previousUv) * frostGradientScale dFdx(previousUv) * frostGradientScale,
).rgb; dFdy(previousUv) * frostGradientScale
current = texture2DGradEXT( ).rgb;
uFrostedTexture, }
uv, if (needsCurrent) {
dFdx(uv) * frostGradientScale, current = texture2DGradEXT(
dFdy(uv) * frostGradientScale uFrostedTexture,
).rgb; uv,
dFdx(uv) * frostGradientScale,
dFdy(uv) * frostGradientScale
).rgb;
}
} else { } else {
previous = texture2D(uPreviousTexture, previousUv).rgb; if (needsPrevious) previous = texture2D(uPreviousTexture, previousUv).rgb;
current = texture2D(uTexture, uv).rgb; if (needsCurrent) current = texture2D(uTexture, uv).rgb;
} }
if (uTextureMix <= 0.001) { if (uTextureMix <= 0.001) {
@@ -929,25 +936,23 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
? refracted ? refracted
: sampleChromatic(sourceUv, detailSeparation); : sampleChromatic(sourceUv, detailSeparation);
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted)); refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular; // 非磨砂不使用扩散结果;预滤磨砂已具备低通纹理,两者都无需额外邻域采样。
float diffusionRadius = if (frosted > 0.5 && usesPrefilteredFrost <= 0.5) {
mix(0.0022, 0.0038, uQuality) * vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
( float diffusionRadius =
0.82 + mix(0.0022, 0.0038, uQuality) *
materialEnergy * mix(0.28, 0.76, uMotionExpansion) + (
flowSurfaceDetail * dynamicMask * 0.38 0.82 +
); materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
float frostedDensity = frosted * (1.0 - uFrostDetailLevel); flowSurfaceDetail * dynamicMask * 0.38
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality); );
vec3 diffused; float frostedDensity = frosted * (1.0 - uFrostDetailLevel);
if (usesPrefilteredFrost > 0.5) { diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
diffused = refracted; vec3 diffused = uQuality > 0.5
} else if (uQuality > 0.5) { ? sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius)
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius); : sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
} else { refracted = mix(refracted, diffused, frosted);
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
} }
refracted = mix(refracted, diffused, frosted);
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722)); float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance)); float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0); float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
@@ -1326,12 +1331,19 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
let contextRecoveryPending = false let contextRecoveryPending = false
let resumePromise: Promise<void> | null = null let resumePromise: Promise<void> | null = null
let resumeVersion = 0 let resumeVersion = 0
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
let dynamicsGeneration = 0 let dynamicsGeneration = 0
const presentationSpace = options.surfaceSpace ?? 'fixed' const presentationSpace = options.surfaceSpace ?? 'fixed'
const usesDynamicsOnly = () => const usesDynamicsOnly = () =>
presentationSpace === 'scroll' || (presentationSpace === 'fixed' && toValue(options.appearance) === 'frosted') presentationSpace === 'scroll' || (presentationSpace === 'fixed' && toValue(options.appearance) === 'frosted')
const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache() const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache()
/** 所有持续绘制入口共享活动边界,资源准备和 uniform 同步不依赖呈现帧。 */
function canPresentFrame() {
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
}
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */ /** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
function syncWallpaperSamplingMode() { function syncWallpaperSamplingMode() {
if (!resources) return if (!resources) return
@@ -1347,18 +1359,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scrollPresentationRestoreTimer = null scrollPresentationRestoreTimer = null
} }
function finishNativeScrollPresentation(timestamp = performance.now()) { function finishNativeScrollPresentation(timestamp = performance.now(), advanceFlow = false) {
clearScrollPresentationRestoreTimer() clearScrollPresentationRestoreTimer()
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed) return if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed || !canPresentFrame()) return
scrollWallpaperSamplingSuppressed = false scrollWallpaperSamplingSuppressed = false
syncWallpaperSamplingMode() syncWallpaperSamplingMode()
renderFrame(timestamp, false) renderFrame(timestamp, advanceFlow)
document.documentElement.removeAttribute('data-glass-scroll-presentation') document.documentElement.removeAttribute('data-glass-scroll-presentation')
} }
function beginNativeScrollPresentation() { function beginNativeScrollPresentation() {
if (presentationSpace !== 'scroll' || !resources) return if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
clearScrollPresentationRestoreTimer() clearScrollPresentationRestoreTimer()
scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180) scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180)
@@ -1532,6 +1544,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
transformingSurfaces.clear() 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() { function clearBackgroundDisposeTimer() {
if (backgroundDisposeTimer === null) return if (backgroundDisposeTimer === null) return
@@ -1584,7 +1608,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
function renderWallpaperTransitionFrame(timestamp: number) { function renderWallpaperTransitionFrame(timestamp: number) {
wallpaperTransitionFrame = null wallpaperTransitionFrame = null
if (document.visibilityState === 'hidden') return if (!canPresentFrame()) return
renderFrame(timestamp) renderFrame(timestamp)
if (previousTexture && wallpaperTransitionFrame === null) { if (previousTexture && wallpaperTransitionFrame === null) {
@@ -1593,7 +1617,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function scheduleWallpaperTransition() { function scheduleWallpaperTransition() {
if (wallpaperTransitionFrame !== null || !previousTexture || document.visibilityState === 'hidden') return if (wallpaperTransitionFrame !== null || !previousTexture || !canPresentFrame()) return
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame) wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
} }
@@ -1821,7 +1845,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function renderFrame(timestamp = performance.now(), advanceFlow = true) { function renderFrame(timestamp = performance.now(), advanceFlow = true) {
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return if (!resources || !canPresentFrame()) return
updateWallpaperTransition(timestamp) updateWallpaperTransition(timestamp)
if (fluidDynamics && advanceFlow) { if (fluidDynamics && advanceFlow) {
@@ -1859,7 +1883,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function scheduleFrame() { function scheduleFrame() {
if (animationFrame !== null || !resources) return if (animationFrame !== null || !resources || !canPresentFrame()) return
animationFrame = requestAnimationFrame(renderScheduledFrame) animationFrame = requestAnimationFrame(renderScheduledFrame)
} }
@@ -2141,11 +2165,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function scheduleSurfaceUpdate() { function scheduleSurfaceUpdate() {
if (!canPresentFrame()) return
if (queueScrollGeometryRefresh(false)) return if (queueScrollGeometryRefresh(false)) return
if (surfaceUpdateFrame !== null || !resources) return if (surfaceUpdateFrame !== null || !resources) return
surfaceUpdateFrame = requestAnimationFrame(timestamp => { surfaceUpdateFrame = requestAnimationFrame(timestamp => {
surfaceUpdateFrame = null surfaceUpdateFrame = null
if (!canPresentFrame()) return
updateSurfaceUniforms(timestamp, false) updateSurfaceUniforms(timestamp, false)
// 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。 // 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。
renderFrame(timestamp, false) renderFrame(timestamp, false)
@@ -2155,6 +2181,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */ /** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
function scheduleSurfaceStabilityUpdate(motionEpoch?: number) { function scheduleSurfaceStabilityUpdate(motionEpoch?: number) {
if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch if (motionEpoch !== undefined) pagePresentationMotionEpoch = motionEpoch
if (!canPresentFrame()) return
if (queueScrollGeometryRefresh(true)) return if (queueScrollGeometryRefresh(true)) return
surfaceStabilityPass = 0 surfaceStabilityPass = 0
surfaceStableFrameCount = 0 surfaceStableFrameCount = 0
@@ -2163,7 +2190,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const sample = (timestamp: number) => { const sample = (timestamp: number) => {
surfaceStabilityFrame = null surfaceStabilityFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return if (!resources || !canPresentFrame()) return
updateSurfaceUniforms(timestamp, false) updateSurfaceUniforms(timestamp, false)
const signature = surfaceSlots const signature = surfaceSlots
@@ -2203,13 +2230,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。 * 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
*/ */
function schedulePresentationResizeUpdate() { function schedulePresentationResizeUpdate() {
if (!canPresentFrame()) return
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer) if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeCandidate = '' presentationResizeCandidate = ''
presentationResizeStableSamples = 0 presentationResizeStableSamples = 0
const sample = () => { const sample = () => {
presentationResizeTimer = null presentationResizeTimer = null
if (!resources) return if (!resources || !canPresentFrame()) return
const presentation = measurePresentationSize() const presentation = measurePresentationSize()
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}` const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
@@ -2231,7 +2259,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */ /** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
function commitActivePagePresentation(timestamp = performance.now()) { 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) if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeTimer = null presentationResizeTimer = null
@@ -2246,7 +2280,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */ /** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
function handleSurfaceResize(entries: ResizeObserverEntry[]) { function handleSurfaceResize(entries: ResizeObserverEntry[]) {
if (!resources) return if (!resources || !canPresentFrame()) return
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot) const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
@@ -2262,12 +2296,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */ /** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
function scheduleSurfaceTransformFrame() { function scheduleSurfaceTransformFrame() {
if (!canPresentFrame()) return
if (queueScrollGeometryRefresh(false)) return if (queueScrollGeometryRefresh(false)) return
if (surfaceTransformFrame !== null || !resources) return if (surfaceTransformFrame !== null || !resources) return
surfaceTransformFrame = requestAnimationFrame(timestamp => { surfaceTransformFrame = requestAnimationFrame(timestamp => {
surfaceTransformFrame = null surfaceTransformFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') { if (!resources || !canPresentFrame()) {
cancelSurfaceTransformFrame() cancelSurfaceTransformFrame()
return return
} }
@@ -2455,7 +2490,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function resizeRenderer() { function resizeRenderer() {
if (!resources) return if (!resources || !canPresentFrame()) return
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
@@ -2660,7 +2695,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function renderInteractionFrame(timestamp: number) { function renderInteractionFrame(timestamp: number) {
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') { if (!resources || !canPresentFrame()) {
animationFrame = null animationFrame = null
interactionAnimating = false interactionAnimating = false
return return
@@ -2725,7 +2760,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function startInteractionAnimation() { function startInteractionAnimation() {
if (interactionAnimating) return if (interactionAnimating || !canPresentFrame()) return
cancelScheduledFrame() cancelScheduledFrame()
interactionAnimating = true interactionAnimating = true
@@ -2742,6 +2777,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
target?: EventTarget | null, target?: EventTarget | null,
) { ) {
if ( if (
!canPresentFrame() ||
!hasDynamicCapability() || !hasDynamicCapability() ||
(hasRippleCapability() && presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed) (hasRippleCapability() && presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed)
) { ) {
@@ -2940,12 +2976,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scrollAnimationFrame = null scrollAnimationFrame = null
scrollFrameCommitted = false scrollFrameCommitted = false
scrollLateGeometryCommitted = false scrollLateGeometryCommitted = false
if ( if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) {
presentationSpace !== 'scroll' ||
!resources ||
!toValue(options.active) ||
document.visibilityState === 'hidden'
) {
scrollDirty = false scrollDirty = false
scrollStableFrameCount = 0 scrollStableFrameCount = 0
return return
@@ -2991,13 +3022,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function scheduleScrollFrame() { function scheduleScrollFrame() {
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources) return if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame) scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
} }
function handleScroll(event: Event) { function handleScroll(event: Event) {
if (presentationSpace !== 'scroll' || !resources) return if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
const target = event.target const target = event.target
if (!isRelevantScrollTarget(target)) return if (!isRelevantScrollTarget(target)) return
@@ -3034,7 +3065,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function handleScrollEnd(event: Event) { function handleScrollEnd(event: Event) {
if (presentationSpace !== 'scroll' || !resources) return if (presentationSpace !== 'scroll' || !resources || !canPresentFrame()) return
const target = event.target const target = event.target
if ( if (
@@ -3052,20 +3083,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
/** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */ /** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */
function pauseRenderer() { function pauseRenderer() {
presentationPaused = true
resumeVersion += 1
resumePromise = null
cancelScheduledFrame() cancelScheduledFrame()
cancelScrollFrame() cancelScrollFrame()
finishNativeScrollPresentation() // 原生滚动背板保持接管,恢复时先提交正确像素再揭示 canvas。
cancelWallpaperTransitionFrame() cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame() cancelSurfaceTransformFrame()
cancelSurfaceUpdateFrames()
interactionAnimating = false interactionAnimating = false
} }
/** 合并同一可见性事务的多个浏览器事件,只恢复一次稳定帧。 */ /** 合并同一可见性事务的多个浏览器事件,只恢复一次稳定帧。 */
function resumeRenderer() { function resumeRenderer() {
if (!canPresentFrame()) return Promise.resolve()
if (resumePromise) return resumePromise if (resumePromise) return resumePromise
const version = resumeVersion const version = resumeVersion
const canResume = () => toValue(options.active) && document.visibilityState !== 'hidden' const canResume = () => canPresentFrame()
const task = (async () => { const task = (async () => {
clearBackgroundDisposeTimer() clearBackgroundDisposeTimer()
if (!canResume()) return if (!canResume()) return
@@ -3074,6 +3110,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (version !== resumeVersion || !canResume()) return if (version !== resumeVersion || !canResume()) return
if (!resources) { if (!resources) {
await initializeRenderer() await initializeRenderer()
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
return return
} }
@@ -3082,12 +3119,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const timestamp = performance.now() const timestamp = performance.now()
const keepRippleAnimating = hasRippleCapability() ? advanceRipple(timestamp) : false const keepRippleAnimating = hasRippleCapability() ? advanceRipple(timestamp) : false
if (!keepRippleAnimating) resetInteractionState() if (!keepRippleAnimating) resetInteractionState()
renderFrame(timestamp, !hasRippleCapability()) if (scrollWallpaperSamplingSuppressed) finishNativeScrollPresentation(timestamp, !hasRippleCapability())
else renderFrame(timestamp, !hasRippleCapability())
if (keepRippleAnimating) { if (keepRippleAnimating) {
interactionAnimating = true interactionAnimating = true
animationFrame = requestAnimationFrame(renderInteractionFrame) animationFrame = requestAnimationFrame(renderInteractionFrame)
} }
scheduleWallpaperTransition() scheduleWallpaperTransition()
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
})() })()
resumePromise = task resumePromise = task
@@ -3111,12 +3150,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function handleVisibilityChange() { function handleVisibilityChange() {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden' || !document.hasFocus()) {
pauseRenderer() pauseRenderer()
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden') scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
return return
} }
presentationPaused = false
void resumeRenderer() void resumeRenderer()
} }
@@ -3127,8 +3167,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus()) scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus())
} }
function handleWindowResume() { function handleWindowResume(event: Event) {
if (document.visibilityState === 'visible') void resumeRenderer() if (document.visibilityState !== 'visible') return
if (event.type === 'pageshow' && !document.hasFocus()) {
handleWindowBlur()
return
}
presentationPaused = false
void resumeRenderer()
} }
function handleContextLost(event: Event) { function handleContextLost(event: Event) {
@@ -3350,21 +3397,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
cancelScrollFrame() cancelScrollFrame()
cancelWallpaperTransitionFrame() cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame() cancelSurfaceTransformFrame()
cancelSurfaceUpdateFrames()
clearBackgroundDisposeTimer() 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() removeEvents()
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
@@ -3994,6 +4028,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
resizeRenderer() resizeRenderer()
await loadWallpaper(toValue(options.wallpaperUrl), version) await loadWallpaper(toValue(options.wallpaperUrl), version)
preparePendingWallpaper() preparePendingWallpaper()
if (version === loadVersion && presentationPaused) {
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden' || !document.hasFocus())
}
} catch (error) { } catch (error) {
fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error) fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error)
} }