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