diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 916e63ff..203c474e 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -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) { 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, 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() + 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() + 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() + 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() + 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() + 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({ diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index 72eac405..13ff1e47 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -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); - previous = texture2DGradEXT( - uPreviousFrostedTexture, - previousUv, - dFdx(previousUv) * frostGradientScale, - dFdy(previousUv) * frostGradientScale - ).rgb; - current = texture2DGradEXT( - uFrostedTexture, - uv, - dFdx(uv) * frostGradientScale, - dFdy(uv) * frostGradientScale - ).rgb; + 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,25 +936,23 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} ? refracted : sampleChromatic(sourceUv, detailSeparation); refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted)); - vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular; - float diffusionRadius = - mix(0.0022, 0.0038, uQuality) * - ( - 0.82 + - materialEnergy * mix(0.28, 0.76, uMotionExpansion) + - flowSurfaceDetail * dynamicMask * 0.38 - ); - 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); + // 非磨砂不使用扩散结果;预滤磨砂已具备低通纹理,两者都无需额外邻域采样。 + if (frosted > 0.5 && usesPrefilteredFrost <= 0.5) { + vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular; + float diffusionRadius = + mix(0.0022, 0.0038, uQuality) * + ( + 0.82 + + materialEnergy * mix(0.28, 0.76, uMotionExpansion) + + flowSurfaceDetail * dynamicMask * 0.38 + ); + float frostedDensity = frosted * (1.0 - uFrostDetailLevel); + diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality); + vec3 diffused = uQuality > 0.5 + ? sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius) + : sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius); + refracted = mix(refracted, diffused, frosted); } - 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 | 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) }