diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 7fc8762b..916e63ff 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -3037,6 +3037,14 @@ describe('glass optical surface discovery', () => { callbacks.clear() scheduledCallbacks.forEach(callback => callback(performance.now())) } + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uPointer) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms as unknown as { + uPointer: { value: { x: number; y: number } } + uTrail: { value: Array<{ x: number; y: number; z: number }> } + } render.mockClear() dispatchTouchEvent('touchstart', [{ clientX: 80, clientY: 180, identifier: 7 }]) @@ -3050,18 +3058,9 @@ describe('glass optical surface discovery', () => { callbacks.clear() interactionFrames.forEach(callback => callback(moveEvent.timeStamp + 16)) - const scene = render.mock.calls.at(-1)?.[0] as unknown as { - children: Array<{ - material: { - uniforms: { - uPointer: { value: { x: number; y: number } } - uTrail: { value: Array<{ x: number; y: number; z: number }> } - } - } - }> - } - const pointer = scene.children[0].material.uniforms.uPointer.value - const trail = scene.children[0].material.uniforms.uTrail.value + expect(render).not.toHaveBeenCalled() + const pointer = uniforms.uPointer.value + const trail = uniforms.uTrail.value expect(pointer.x).toBeCloseTo(180 / 390) expect(pointer.y).toBeCloseTo(1 - 320 / 844) expect(trail[0]).toMatchObject({ z: 1 }) @@ -3633,6 +3632,16 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now())) } expect(callbacks.size).toBe(0) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uScrollOffset) + if (!mainScene) throw new Error('main optical scene was not rendered') + const settledUniforms = mainScene.children[0].material!.uniforms as unknown as { + uMotion: { value: number } + uRects: { value: Array<{ y: number }> } + uScrollOffset: { value: { y: number } } + uTrail: { value: Array<{ z: number }> } + } vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120) setOpticalSurfaceBounds(surface, { height: 500, width: 350, x: 20, y: -20 }) @@ -3643,19 +3652,6 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now() + 160 + pass * 16)) } - const settledScene = render.mock.calls.at(-1)?.[0] as unknown as { - children: Array<{ - material: { - uniforms: { - uMotion: { value: number } - uRects: { value: Array<{ y: number }> } - uScrollOffset: { value: { y: number } } - uTrail: { value: Array<{ z: number }> } - } - } - }> - } - const settledUniforms = settledScene.children[0].material.uniforms expect(settledUniforms.uMotion.value).toBe(0) expect(settledUniforms.uTrail.value.every(trail => trail.z === 0)).toBe(true) expect(settledUniforms.uRects.value[0].y).toBeCloseTo(1 - (100 + 500) / 844) @@ -3705,6 +3701,15 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) } expect(callbacks.size).toBe(0) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uScrollOffset) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms as unknown as { + uMotion: { value: number } + uScrollOffset: { value: { y: number } } + uTrail: { value: Array<{ z: number }> } + } render.mockClear() scrollY = 80 @@ -3719,18 +3724,6 @@ describe('glass optical surface discovery', () => { callbacks.clear() scheduledCallbacks.forEach(callback => { callback(performance.now() + 100 + pass * 16) - const scene = render.mock.calls.at(-1)?.[0] as unknown as { - children: Array<{ - material: { - uniforms: { - uMotion: { value: number } - uScrollOffset: { value: { y: number } } - uTrail: { value: Array<{ z: number }> } - } - } - }> - } - const uniforms = scene.children[0].material.uniforms renderedScrollOffsets.push(uniforms.uScrollOffset.value.y) expect(uniforms.uMotion.value).toBe(0) expect(uniforms.uTrail.value.every(trail => trail.z === 0)).toBe(true) @@ -3763,21 +3756,6 @@ describe('glass optical surface discovery', () => { vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => scrollY) const three = await import('three') const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') - const wallpaperSampling: number[] = [] - render.mockImplementation(scene => { - const uniforms = ( - scene as unknown as { - children: Array<{ - material?: { - uniforms?: { - uHasWallpaperTexture?: { value: number } - } - } - }> - } - ).children[0]?.material?.uniforms - if (uniforms?.uHasWallpaperTexture) wallpaperSampling.push(uniforms.uHasWallpaperTexture.value) - }) const callbacks = new Map() let frameId = 0 vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { @@ -3808,11 +3786,16 @@ describe('glass optical surface discovery', () => { callbacks.clear() scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) } - expect(wallpaperSampling.at(-1)).toBe(1) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uHasWallpaperTexture) + if (!mainScene) throw new Error('main optical scene was not rendered') + const wallpaperSampling = mainScene.children[0].material!.uniforms.uHasWallpaperTexture + expect(wallpaperSampling.value).toBe(1) window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 })) - expect(wallpaperSampling.at(-1)).toBe(0) + expect(wallpaperSampling.value).toBe(0) expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') scrollY = 240 @@ -3823,13 +3806,152 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now() + 100 + pass * 16)) } - expect(wallpaperSampling.at(-1)).toBe(1) + expect(wallpaperSampling.value).toBe(1) expect(document.documentElement).not.toHaveAttribute('data-glass-scroll-presentation') expect(callbacks.size).toBe(0) scope.stop() }) + it('skips hidden native scroll draws and commits one frame before revealing the canvas', async () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) + let scrollY = 0 + vi.spyOn(window, 'scrollY', 'get').mockImplementation(() => scrollY) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + 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)) + appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'), + quality: ref('high'), + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(document.documentElement.dataset.glassRendererState).toBe('ready') + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uScrollOffset) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms + const framesBeforeNativeScroll = renderer?.renderedFrames.value + let presentationAtRestoreRender: string | undefined + render.mockImplementation(() => { + presentationAtRestoreRender = document.documentElement.dataset.glassScrollPresentation + }) + render.mockClear() + + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 })) + + expect(render).not.toHaveBeenCalled() + expect(renderer?.renderedFrames.value).toBe(framesBeforeNativeScroll) + expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') + + scrollY = 240 + window.dispatchEvent(new Event('scroll')) + const firstScrollFrame = [...callbacks.values()][0] + callbacks.clear() + firstScrollFrame?.(performance.now() + 100) + + expect(render).not.toHaveBeenCalled() + expect(uniforms.uScrollOffset.value.y).toBe(240) + expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') + + const secondScrollFrame = [...callbacks.values()][0] + callbacks.clear() + secondScrollFrame?.(performance.now() + 116) + + expect(render).not.toHaveBeenCalled() + expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') + + const restoreFrame = [...callbacks.values()][0] + callbacks.clear() + restoreFrame?.(performance.now() + 132) + + expect(render).toHaveBeenCalledTimes(1) + expect(presentationAtRestoreRender).toBe('native') + expect(renderer?.renderedFrames.value).toBe((framesBeforeNativeScroll ?? 0) + 1) + expect(document.documentElement).not.toHaveAttribute('data-glass-scroll-presentation') + + scope.stop() + }) + + it.each(['loading', 'fallback'] as const)( + 'keeps drawing while the scroll context is ready but the composite renderer state is %s', + async rootState => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const 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)) + appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode: ref<'fluid' | 'off' | 'ripple'>('off'), + quality: ref('high'), + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) + } + render.mockClear() + + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 })) + expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') + expect(render).not.toHaveBeenCalled() + + document.documentElement.dataset.glassRendererState = rootState + window.dispatchEvent(new Event('scroll')) + const firstScrollFrame = [...callbacks.values()][0] + callbacks.clear() + firstScrollFrame?.(performance.now() + 100) + + expect(renderer?.state.value).toBe('ready') + expect(document.documentElement.dataset.glassRendererState).toBe(rootState) + expect(render).toHaveBeenCalledTimes(1) + + scope.stop() + }, + ) + it('clears ripple state before native scroll presentation takes ownership', async () => { vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) @@ -4120,6 +4242,14 @@ describe('glass optical surface discovery', () => { callbacks.clear() scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) } + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uRects) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms as unknown as { + uRects: { value: Array<{ y: number }> } + } + const initialRectY = uniforms.uRects.value[0]?.y render.mockClear() setRenderTarget.mockClear() @@ -4135,7 +4265,8 @@ describe('glass optical surface discovery', () => { callbacks.clear() scheduledCallbacks.forEach(callback => callback(performance.now() + 100)) - expect(render).toHaveBeenCalled() + expect(render).not.toHaveBeenCalled() + expect(uniforms.uRects.value[0]?.y).not.toBe(initialRectY) expect(setRenderTarget).not.toHaveBeenCalled() const transitionEnd = new Event('transitionend', { bubbles: true }) as TransitionEvent @@ -4199,6 +4330,13 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) } expect(callbacks.size).toBe(0) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uRects) + if (!mainScene) throw new Error('main optical scene was not rendered') + const scrollUniforms = mainScene.children[0].material!.uniforms as unknown as { + uRects: { value: Array<{ y: number }> } + } querySelectorAll.mockClear() scrollY = 1000 @@ -4209,16 +4347,7 @@ describe('glass optical surface discovery', () => { const firstScrollFrame = [...callbacks.values()] callbacks.clear() firstScrollFrame.forEach(callback => callback(performance.now() + 100)) - const firstScrollScene = render.mock.calls.at(-1)?.[0] as unknown as { - children: Array<{ - material: { - uniforms: { - uRects: { value: Array<{ y: number }> } - } - } - }> - } - expect(firstScrollScene.children[0].material.uniforms.uRects.value[0].y).toBeCloseTo(1 - (1100 + 240) / 2000) + expect(scrollUniforms.uRects.value[0].y).toBeCloseTo(1 - (1100 + 240) / 2000) expect(querySelectorAll).not.toHaveBeenCalled() for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) { @@ -4303,6 +4432,13 @@ describe('glass optical surface discovery', () => { scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) } expect(callbacks.size).toBe(0) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uRects) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms as unknown as { + uRects: { value: Array<{ y: number }> } + } querySelectorAll.mockClear() setOpticalSurfaceBounds(surface, { height: 300, width: 400, x: 40, y: 180 }) @@ -4311,17 +4447,7 @@ describe('glass optical surface discovery', () => { const firstScrollFrame = [...callbacks.values()] callbacks.clear() firstScrollFrame.forEach(callback => callback(performance.now() + 100)) - - const firstScrollScene = render.mock.calls.at(-1)?.[0] as unknown as { - children: Array<{ - material: { - uniforms: { - uRects: { value: Array<{ y: number }> } - } - } - }> - } - expect(firstScrollScene.children[0].material.uniforms.uRects.value[0].y).toBeCloseTo(1 - (180 + 300) / 1200) + expect(uniforms.uRects.value[0].y).toBeCloseTo(1 - (180 + 300) / 1200) expect(querySelectorAll).toHaveBeenCalled() scope.stop() diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index d3cc57b4..72eac405 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -1827,6 +1827,17 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (fluidDynamics && advanceFlow) { resources.uniforms.uFlowTexture.value = fluidDynamics.step() } + // 原生 backdrop 接管期间 scroll canvas 已由 CSS 隐藏;保留事务、流场和 uniform 同步,跳过主材质输出。 + if ( + presentationSpace === 'scroll' && + scrollWallpaperSamplingSuppressed && + state.value === 'ready' && + document.documentElement.dataset.glassRendererState === 'ready' && + document.documentElement.dataset.glassScrollPresentation === 'native' + ) { + return + } + if (presentationSpace === 'scroll') { const { height: presentationHeight } = getCommittedPresentationSize() const scaleY = presentationBufferHeight / Math.max(presentationHeight, 1)