mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
fix(glass): stabilize scroll material and shared dynamics (#598)
This commit is contained in:
@@ -157,8 +157,8 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 66,
|
||||
glassFlowStrength: 64,
|
||||
glassDeformationStrength: 79,
|
||||
glassFlowStrength: 77,
|
||||
glassPreset: 'liquid',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:glide': {
|
||||
@@ -171,10 +171,10 @@ describe('GlassSettingsDialog', () => {
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 31,
|
||||
glassTransmissionStrength: 47,
|
||||
glassTranslationStrength: 43,
|
||||
glassTransparencyStrength: 32,
|
||||
glassReflectionStrength: 37,
|
||||
glassTransmissionStrength: 56,
|
||||
glassTranslationStrength: 52,
|
||||
glassTransparencyStrength: 44,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getDefaultGlassCustomizerSettings,
|
||||
themeCustomizerPrimaryColors,
|
||||
themeCustomizerShadowLevels,
|
||||
useThemeCustomizer,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { GLASS_OPTICAL_STRENGTH_DEFAULT, getGlassOpticalPresetParameters } from '@/utils/glassOptics'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
@@ -38,7 +38,7 @@ const { appMode } = usePWA()
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const defaultGlassTransmissionStrength = getGlassOpticalPresetParameters('clear', 'balanced', 'natural').transmission
|
||||
const defaultAppModeGlassSettings = getDefaultGlassCustomizerSettings('css')
|
||||
|
||||
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
|
||||
function syncThemeCustomizerOpenState(isOpen: boolean) {
|
||||
@@ -141,14 +141,14 @@ const showShadowSection = computed(() => globalTheme.name.value !== 'glass')
|
||||
const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== 'clear' ||
|
||||
settings.value.glassDeformationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassFlowStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassQuality !== 'css' ||
|
||||
settings.value.glassReflectionStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTransmissionStrength !== defaultGlassTransmissionStrength ||
|
||||
settings.value.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
|
||||
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
|
||||
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
|
||||
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
|
||||
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
|
||||
settings.value.glassTransmissionStrength !== defaultAppModeGlassSettings.glassTransmissionStrength ||
|
||||
settings.value.glassTranslationStrength !== defaultAppModeGlassSettings.glassTranslationStrength ||
|
||||
settings.value.glassTransparencyStrength !== defaultAppModeGlassSettings.glassTransparencyStrength ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
|
||||
@@ -1746,7 +1746,7 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clips nested hover-card dynamics on shared page surfaces without allocating another material slot', async () => {
|
||||
it('shares dynamics across nested hover cards without allocating another material slot', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
@@ -1756,14 +1756,20 @@ describe('glass optical surface discovery', () => {
|
||||
const outerSurface = document.createElement('section')
|
||||
outerSurface.className = 'v-card'
|
||||
setOpticalSurfaceBounds(outerSurface, { height: 420, width: 900, x: 40, y: 80 })
|
||||
const nestedCard = document.createElement('article')
|
||||
nestedCard.className = 'app-hover-lift-card'
|
||||
nestedCard.style.borderTopLeftRadius = '16px'
|
||||
nestedCard.style.borderTopRightRadius = '16px'
|
||||
nestedCard.style.borderBottomRightRadius = '16px'
|
||||
nestedCard.style.borderBottomLeftRadius = '16px'
|
||||
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x: 80, y: 140 })
|
||||
outerSurface.append(nestedCard)
|
||||
const nestedCardSpecs = [
|
||||
{ radius: 16, x: 80 },
|
||||
{ radius: 20, x: 400 },
|
||||
]
|
||||
nestedCardSpecs.forEach(({ radius, x }) => {
|
||||
const nestedCard = document.createElement('article')
|
||||
nestedCard.className = 'app-hover-lift-card'
|
||||
nestedCard.style.borderTopLeftRadius = `${radius}px`
|
||||
nestedCard.style.borderTopRightRadius = `${radius}px`
|
||||
nestedCard.style.borderBottomRightRadius = `${radius}px`
|
||||
nestedCard.style.borderBottomLeftRadius = `${radius}px`
|
||||
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x, y: 140 })
|
||||
outerSurface.append(nestedCard)
|
||||
})
|
||||
pageContent.append(outerSurface)
|
||||
document.body.append(pageContent)
|
||||
const scope = effectScope()
|
||||
@@ -1789,9 +1795,9 @@ describe('glass optical surface discovery', () => {
|
||||
material: {
|
||||
fragmentShader: string
|
||||
uniforms: {
|
||||
uHasInteractionClip: { value: number }
|
||||
uInteractionRadii: { value: { toArray: () => number[] } }
|
||||
uInteractionRect: { value: { toArray: () => number[] } }
|
||||
uInteractionRadii: { value: Array<{ toArray: () => number[] }> }
|
||||
uInteractionRectCount: { value: number }
|
||||
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
|
||||
uRectCount: { value: number }
|
||||
}
|
||||
}
|
||||
@@ -1800,15 +1806,23 @@ describe('glass optical surface discovery', () => {
|
||||
const material = scene.children[0].material
|
||||
|
||||
expect(material.uniforms.uRectCount.value).toBe(1)
|
||||
expect(material.uniforms.uHasInteractionClip.value).toBe(1)
|
||||
expect(material.uniforms.uInteractionRect.value.toArray()).toEqual([
|
||||
expect(material.uniforms.uInteractionRectCount.value).toBe(2)
|
||||
expect(material.uniforms.uInteractionRects.value[0].toArray()).toEqual([
|
||||
80 / 1200,
|
||||
1 - (140 + 160) / 800,
|
||||
280 / 1200,
|
||||
160 / 800,
|
||||
])
|
||||
expect(material.uniforms.uInteractionRadii.value.toArray()).toEqual([16, 16, 16, 16])
|
||||
expect(material.fragmentShader).toContain('uniform vec4 uInteractionRect')
|
||||
expect(material.uniforms.uInteractionRects.value[1].toArray()).toEqual([
|
||||
400 / 1200,
|
||||
1 - (140 + 160) / 800,
|
||||
280 / 1200,
|
||||
160 / 800,
|
||||
])
|
||||
expect(material.uniforms.uInteractionRadii.value[0].toArray()).toEqual([16, 16, 16, 16])
|
||||
expect(material.uniforms.uInteractionRadii.value[1].toArray()).toEqual([20, 20, 20, 20])
|
||||
expect(material.fragmentShader).toContain('uniform vec4 uInteractionRects[8]')
|
||||
expect(material.fragmentShader).toContain('interactionMask = max(')
|
||||
expect(material.fragmentShader).toContain('surfaceDynamic * interactionMask')
|
||||
scope.stop()
|
||||
})
|
||||
@@ -2251,6 +2265,80 @@ describe('glass optical surface discovery', () => {
|
||||
expect(callbacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('hands wallpaper sampling to the native scroll material before wheel movement and restores it after settling', 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 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<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))
|
||||
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')),
|
||||
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))
|
||||
}
|
||||
expect(wallpaperSampling.at(-1)).toBe(1)
|
||||
|
||||
window.dispatchEvent(new WheelEvent('wheel'))
|
||||
|
||||
expect(wallpaperSampling.at(-1)).toBe(0)
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||
|
||||
scrollY = 240
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + 100 + pass * 16))
|
||||
}
|
||||
|
||||
expect(wallpaperSampling.at(-1)).toBe(1)
|
||||
expect(document.documentElement).not.toHaveAttribute('data-glass-scroll-presentation')
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('redraws a high-quality scroll layer without advancing its flow targets', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
@@ -2410,6 +2498,8 @@ describe('glass optical surface discovery', () => {
|
||||
Object.defineProperty(transitionRun, 'propertyName', { value: 'transform' })
|
||||
surface.dispatchEvent(transitionRun)
|
||||
expect(callbacks.size).toBeGreaterThan(0)
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
@@ -2457,6 +2547,7 @@ describe('glass optical surface discovery', () => {
|
||||
x: 80,
|
||||
y: 1100,
|
||||
})
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
@@ -2478,11 +2569,13 @@ describe('glass optical surface discovery', () => {
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
querySelectorAll.mockClear()
|
||||
|
||||
scrollY = 1000
|
||||
setOpticalSurfaceBounds(firstSurface, { height: 240, width: 360, x: 80, y: -900 })
|
||||
setOpticalSurfaceBounds(secondSurface, { height: 240, width: 360, x: 80, y: 100 })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
expect(callbacks.size).toBe(1)
|
||||
const firstScrollFrame = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
firstScrollFrame.forEach(callback => callback(performance.now() + 100))
|
||||
@@ -2496,6 +2589,7 @@ describe('glass optical surface discovery', () => {
|
||||
}>
|
||||
}
|
||||
expect(firstScrollScene.children[0].material.uniforms.uRects.value[0].y).toBeCloseTo(1 - (1100 + 240) / 2000)
|
||||
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||
|
||||
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
@@ -2503,6 +2597,13 @@ describe('glass optical surface discovery', () => {
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + 116 + pass * 16))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
window.dispatchEvent(new Event('scrollend'))
|
||||
for (let pass = 0; pass < 2 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + 164 + pass * 16))
|
||||
}
|
||||
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||
|
||||
render.mockClear()
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
@@ -2526,6 +2627,208 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('refreshes affected surface geometry in the first nested scroll frame', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(1200)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
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 scroller = document.createElement('div')
|
||||
document.body.append(scroller)
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 300,
|
||||
width: 400,
|
||||
x: 40,
|
||||
y: 100,
|
||||
})
|
||||
scroller.append(surface)
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
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'))
|
||||
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))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
querySelectorAll.mockClear()
|
||||
|
||||
setOpticalSurfaceBounds(surface, { height: 300, width: 400, x: 40, y: 180 })
|
||||
scroller.dispatchEvent(new Event('scroll'))
|
||||
expect(callbacks.size).toBe(1)
|
||||
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(querySelectorAll).toHaveBeenCalled()
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('coalesces virtual-list replacements into one scroll geometry pass', 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)
|
||||
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(2400)
|
||||
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 surface = appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 300,
|
||||
width: 400,
|
||||
x: 40,
|
||||
y: 100,
|
||||
})
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/resource'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
querySelectorAll.mockClear()
|
||||
|
||||
scrollY = 400
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
surface.remove()
|
||||
appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 300,
|
||||
width: 400,
|
||||
x: 40,
|
||||
y: 100,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(callbacks.size).toBe(1)
|
||||
const firstScrollFrame = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
firstScrollFrame.forEach(callback => callback(performance.now() + 100))
|
||||
expect(querySelectorAll).toHaveBeenCalledTimes(5)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('commits a virtual-list replacement that lands after the scroll frame before the next paint', 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)
|
||||
vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue(2400)
|
||||
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 surface = appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 300,
|
||||
width: 400,
|
||||
x: 40,
|
||||
y: 100,
|
||||
})
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/resource'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
scrollY = 400
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
const firstScrollFrame = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
firstScrollFrame.forEach(callback => callback(performance.now() + 100))
|
||||
expect(callbacks.size).toBe(1)
|
||||
querySelectorAll.mockClear()
|
||||
|
||||
surface.remove()
|
||||
appendOpticalSurface('app-hover-lift-card', {
|
||||
height: 300,
|
||||
width: 400,
|
||||
x: 40,
|
||||
y: 100,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(querySelectorAll).toHaveBeenCalledTimes(5)
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps local material response stable while moving from card A through a gap to card B', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(700)
|
||||
@@ -2597,6 +2900,7 @@ describe('glass optical surface discovery', () => {
|
||||
material: {
|
||||
uniforms: {
|
||||
uDeformationStrength: { value: number }
|
||||
uDynamicsOnly: { value: number }
|
||||
uFlowStrength: { value: number }
|
||||
uMaxRefractionPixels: { value: number }
|
||||
uMotionExpansion: { value: number }
|
||||
@@ -2608,6 +2912,8 @@ describe('glass optical surface discovery', () => {
|
||||
uTintDensity: { value: number }
|
||||
uTransmissionStrength: { value: number }
|
||||
uTranslationStrength: { value: number }
|
||||
uInteractionRectCount: { value: number }
|
||||
uTrail: { value: Array<{ z: number }> }
|
||||
uSurfaceWeights: { value: number[] }
|
||||
}
|
||||
fragmentShader: string
|
||||
@@ -2629,8 +2935,11 @@ describe('glass optical surface discovery', () => {
|
||||
expect(cardBIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(uniforms.uSurfaceWeights.value[cardAIndex]).toBe(1)
|
||||
expect(uniforms.uSurfaceWeights.value[cardBIndex]).toBe(1)
|
||||
expect(uniforms.uInteractionRectCount.value).toBe(2)
|
||||
expect(uniforms.uTrail.value[1].z).toBeGreaterThan(0)
|
||||
expect(uniforms.uTranslationStrength.value).toBe(1)
|
||||
expect(uniforms.uDeformationStrength.value).toBe(1)
|
||||
expect(uniforms.uDynamicsOnly.value).toBe(1)
|
||||
expect(uniforms.uFlowStrength.value).toBe(1)
|
||||
expect(uniforms.uMotionExpansion.value).toBeCloseTo(0.5 ** 1.4)
|
||||
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
|
||||
@@ -2659,6 +2968,7 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform vec4 uInteractionRects[8]')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('getContentProtection')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('sampleHighQualityDiffuse')
|
||||
@@ -2672,12 +2982,28 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uMotionExpansion')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDynamicsOnly')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float sharedWaveDensity = mix(2.81, 1.63')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'mix(radialPointerShape, directionalPointerShape, sharedDirectionality)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'mix(radialSharedWave, directionalSharedWave, sharedDirectionality)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('mix(1.0, 0.78, sharedDirectionality)')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uMotion *\n uMotion')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uFlowStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('uWakeProgress')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('temporalEnergy')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeScale = 0.65')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeDensity = 2.367')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeScale = 0.40')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeDensity = 6.250')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float pointerSpread = mix(26.0, 17.0, uQuality)')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain(
|
||||
'mix(mix(26.0, 17.0, uQuality), mix(12.0, 8.0, uQuality), frosted)',
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
applyThemeCustomizerRootSettings,
|
||||
cancelGlassPreview,
|
||||
commitGlassPreview,
|
||||
getDefaultGlassCustomizerSettings,
|
||||
isDefaultThemeCustomizerSettings,
|
||||
persistPartialThemeCustomizerSettings,
|
||||
previewGlassSettings,
|
||||
readThemeCustomizerSettings,
|
||||
@@ -44,15 +46,47 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(40)
|
||||
expect(settings.glassFlowStrength).toBe(40)
|
||||
expect(settings.glassDeformationStrength).toBe(48)
|
||||
expect(settings.glassFlowStrength).toBe(48)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toEqual({})
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
expect(settings.glassReflectionStrength).toBe(35)
|
||||
expect(settings.glassTransmissionStrength).toBe(54)
|
||||
expect(settings.glassTranslationStrength).toBe(40)
|
||||
expect(settings.glassTransparencyStrength).toBe(46)
|
||||
expect(settings.glassReflectionStrength).toBe(42)
|
||||
expect(settings.glassTransmissionStrength).toBe(65)
|
||||
expect(settings.glassTranslationStrength).toBe(48)
|
||||
expect(settings.glassTransparencyStrength).toBe(50)
|
||||
})
|
||||
|
||||
it('recognizes matrix-derived reset settings as the default state', async () => {
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
await customizer.setGlassDeformationStrength(73)
|
||||
expect(customizer.isCustomized.value).toBe(true)
|
||||
|
||||
await customizer.resetSettings()
|
||||
expect(customizer.settings.value).toMatchObject(getDefaultGlassCustomizerSettings('balanced'))
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('derives app-mode glass reset values from the standard-quality matrix', () => {
|
||||
expect(getDefaultGlassCustomizerSettings('css')).toEqual({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 48,
|
||||
glassFlowStrength: 48,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 42,
|
||||
glassTransmissionStrength: 67,
|
||||
glassTranslationStrength: 48,
|
||||
glassTransparencyStrength: 52,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
||||
@@ -122,12 +156,12 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 40,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
deformation: 48,
|
||||
flow: 48,
|
||||
reflection: 42,
|
||||
transmission: 50,
|
||||
translation: 40,
|
||||
transparency: 46,
|
||||
translation: 48,
|
||||
transparency: 50,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -146,16 +180,16 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.documentElement.dataset.glassQuality).toBe('high')
|
||||
expect(document.body.dataset.glassAppearance).toBe('tinted')
|
||||
expect(document.body.dataset.glassQuality).toBe('high')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(54 / 70)
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70)
|
||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
||||
0.46639,
|
||||
0.48,
|
||||
)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.46639)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72972)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.66215)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
|
||||
})
|
||||
|
||||
it('previews glass settings without persisting them', () => {
|
||||
@@ -320,14 +354,14 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 32,
|
||||
glassFlowStrength: 37,
|
||||
glassDeformationStrength: 38,
|
||||
glassFlowStrength: 44,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 25,
|
||||
glassTransmissionStrength: 54,
|
||||
glassTranslationStrength: 56,
|
||||
glassTransparencyStrength: 38,
|
||||
glassReflectionStrength: 30,
|
||||
glassTransmissionStrength: 65,
|
||||
glassTranslationStrength: 67,
|
||||
glassTransparencyStrength: 53,
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -350,20 +384,20 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassDeformationStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
deformation: 73,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 54,
|
||||
translation: 40,
|
||||
flow: 48,
|
||||
reflection: 42,
|
||||
transmission: 65,
|
||||
translation: 48,
|
||||
transparency: 27,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassTransparencyStrength: 30,
|
||||
glassTransparencyStrength: 32,
|
||||
})
|
||||
await setGlassQuality('balanced')
|
||||
await setGlassAppearance('clear')
|
||||
@@ -389,7 +423,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'css',
|
||||
glassTransparencyStrength: 48,
|
||||
glassTransparencyStrength: 52,
|
||||
})
|
||||
|
||||
await setGlassTransparencyStrength(19)
|
||||
@@ -397,7 +431,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'balanced',
|
||||
glassTransparencyStrength: 46,
|
||||
glassTransparencyStrength: 50,
|
||||
})
|
||||
await setGlassQuality('css')
|
||||
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(19)
|
||||
|
||||
@@ -237,14 +237,15 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
||||
uBackgroundVisibility: IUniform<number>
|
||||
uCoverScale: IUniform<Vector2>
|
||||
uDeformationStrength: IUniform<number>
|
||||
uDynamicsOnly: IUniform<number>
|
||||
uFlowTexture: IUniform<Texture | null>
|
||||
uFlowStrength: IUniform<number>
|
||||
uHasWallpaperTexture: IUniform<number>
|
||||
uHasFlowTexture: IUniform<number>
|
||||
uHasFrostedTexture: IUniform<number>
|
||||
uHasInteractionClip: IUniform<number>
|
||||
uInteractionRadii: IUniform<Vector4>
|
||||
uInteractionRect: IUniform<Vector4>
|
||||
uInteractionRadii: IUniform<Vector4[]>
|
||||
uInteractionRectCount: IUniform<number>
|
||||
uInteractionRects: IUniform<Vector4[]>
|
||||
uMotion: IUniform<number>
|
||||
uMotionExpansion: IUniform<number>
|
||||
uMaxRefractionPixels: IUniform<number>
|
||||
@@ -423,7 +424,7 @@ void main() {
|
||||
}
|
||||
`
|
||||
|
||||
const DYNAMIC_RANGE_SCALE = 0.65
|
||||
const DYNAMIC_RANGE_SCALE = 0.4
|
||||
const DYNAMIC_RANGE_DENSITY = 1 / DYNAMIC_RANGE_SCALE ** 2
|
||||
|
||||
const FLOW_FRAGMENT_SHADER = `
|
||||
@@ -526,13 +527,14 @@ uniform vec2 uPresentationSize;
|
||||
uniform vec2 uScrollOffset;
|
||||
uniform vec2 uVisibleViewportSize;
|
||||
uniform float uDeformationStrength;
|
||||
uniform float uDynamicsOnly;
|
||||
uniform float uFlowStrength;
|
||||
uniform float uHasWallpaperTexture;
|
||||
uniform float uHasFlowTexture;
|
||||
uniform float uHasFrostedTexture;
|
||||
uniform float uHasInteractionClip;
|
||||
uniform vec4 uInteractionRadii;
|
||||
uniform vec4 uInteractionRect;
|
||||
uniform vec4 uInteractionRadii[8];
|
||||
uniform vec4 uInteractionRects[8];
|
||||
uniform int uInteractionRectCount;
|
||||
uniform float uMotion;
|
||||
uniform float uMotionExpansion;
|
||||
uniform float uMaxRefractionPixels;
|
||||
@@ -627,7 +629,10 @@ vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
|
||||
float highTransmissionProgress =
|
||||
clamp((uTransmissionStrength - 1.0) / 0.3, 0.0, 1.0);
|
||||
float transmissionMaterialScale = mix(1.0, 0.9, tinted);
|
||||
transmissionMaterialScale = mix(transmissionMaterialScale, 0.42, frosted);
|
||||
float frostedTransparencyProgress =
|
||||
smoothstep(0.6, 0.96, uBackgroundVisibility);
|
||||
float frostedTransmissionScale = mix(0.42, 0.72, frostedTransparencyProgress);
|
||||
transmissionMaterialScale = mix(transmissionMaterialScale, frostedTransmissionScale, frosted);
|
||||
float highlightProtection = smoothstep(0.68, 0.92, luminance);
|
||||
vec3 transmissionReference = normalized;
|
||||
float referenceLift =
|
||||
@@ -763,6 +768,7 @@ void main() {
|
||||
float topPrism = 0.0;
|
||||
float backlightAbsorption = 0.0;
|
||||
float materialEnergy = 0.0;
|
||||
float sharedMotionPresence = 0.0;
|
||||
float dynamicMask = 0.0;
|
||||
vec2 staticRefraction = vec2(0.0);
|
||||
vec2 dynamicRefraction = vec2(0.0);
|
||||
@@ -770,16 +776,23 @@ void main() {
|
||||
vec2 wakePerpendicular = vec2(-wakeDirection.y, wakeDirection.x);
|
||||
vec2 trailRefraction = vec2(0.0);
|
||||
float trailEnergy = 0.0;
|
||||
float trailSpatialSpan = 0.0;
|
||||
float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion);
|
||||
const float dynamicRangeScale = ${DYNAMIC_RANGE_SCALE.toFixed(2)};
|
||||
const float dynamicRangeDensity = ${DYNAMIC_RANGE_DENSITY.toFixed(3)};
|
||||
float interactionMask = 1.0;
|
||||
if (uHasInteractionClip > 0.5) {
|
||||
vec2 interactionLocal = (vUv - uInteractionRect.xy) / max(uInteractionRect.zw, vec2(0.0001));
|
||||
interactionMask = roundedRectMask(
|
||||
interactionLocal,
|
||||
uInteractionRect.zw * uPresentationSize,
|
||||
uInteractionRadii
|
||||
float interactionMask = uInteractionRectCount > 0 ? 0.0 : 1.0;
|
||||
for (int interactionIndex = 0; interactionIndex < 8; interactionIndex++) {
|
||||
if (interactionIndex >= uInteractionRectCount) break;
|
||||
|
||||
vec4 interactionRect = uInteractionRects[interactionIndex];
|
||||
vec2 interactionLocal = (vUv - interactionRect.xy) / max(interactionRect.zw, vec2(0.0001));
|
||||
interactionMask = max(
|
||||
interactionMask,
|
||||
roundedRectMask(
|
||||
interactionLocal,
|
||||
interactionRect.zw * uPresentationSize,
|
||||
uInteractionRadii[interactionIndex]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -789,6 +802,9 @@ void main() {
|
||||
vec4 trail = uTrail[trailIndex];
|
||||
vec2 trailDelta = vUv - trail.xy;
|
||||
trailDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
vec2 trailSpanDelta = trail.xy - uPointer;
|
||||
trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z);
|
||||
float along = dot(trailDelta, wakeDirection);
|
||||
float across = dot(trailDelta, wakePerpendicular);
|
||||
float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity;
|
||||
@@ -858,8 +874,33 @@ void main() {
|
||||
// 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。
|
||||
float pointerSpread = mix(26.0, 17.0, uQuality);
|
||||
pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion);
|
||||
float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan);
|
||||
float pointerAlong = dot(-pointerDeltaAspect, wakeDirection);
|
||||
float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular);
|
||||
float sharedWakeTravel =
|
||||
0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion);
|
||||
float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread);
|
||||
float directionalPointerShape =
|
||||
exp(-(
|
||||
pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 +
|
||||
pointerAcross * pointerAcross * pointerSpread * 1.35
|
||||
));
|
||||
float pointerEnergy =
|
||||
clamp(exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread) * uMotion, 0.0, 1.0);
|
||||
clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0);
|
||||
float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion);
|
||||
float radialSharedWave =
|
||||
exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity);
|
||||
float directionalSharedWave =
|
||||
exp(-(
|
||||
pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 +
|
||||
pointerAcross * pointerAcross * sharedWaveDensity * 2.2
|
||||
));
|
||||
float sharedWaveEnergy =
|
||||
mix(radialSharedWave, directionalSharedWave, sharedDirectionality) *
|
||||
clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) *
|
||||
mix(1.0, 0.78, sharedDirectionality) *
|
||||
uMotion *
|
||||
uMotion;
|
||||
vec2 wakeDelta = vUv - uPointer;
|
||||
wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
float wakeAlong = dot(wakeDelta, wakeDirection);
|
||||
@@ -930,6 +971,10 @@ void main() {
|
||||
topPrism = max(topPrism, localTopPrism);
|
||||
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
|
||||
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask);
|
||||
sharedMotionPresence = max(
|
||||
sharedMotionPresence,
|
||||
sharedWaveEnergy * rectMask * surfaceDynamic * interactionMask
|
||||
);
|
||||
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask);
|
||||
mask = max(mask, rectMask);
|
||||
}
|
||||
@@ -1003,7 +1048,7 @@ void main() {
|
||||
highlight = vec3(0.94, 0.97, 1.0);
|
||||
edgeHighlightMix = 0.15;
|
||||
causticHighlightMix = 0.042;
|
||||
float frostedBaseAlpha = mix(0.72, 0.9, uSurfaceDensity);
|
||||
float frostedBaseAlpha = mix(0.46, 0.88, uSurfaceDensity);
|
||||
materialAlpha = frostedBaseAlpha * mix(0.9, 1.0, liquidPresence);
|
||||
proceduralEdgeAlpha = 0.16;
|
||||
proceduralCausticAlpha = 0.045;
|
||||
@@ -1047,6 +1092,14 @@ void main() {
|
||||
refracted = mix(refracted, highlight, reflectionMix);
|
||||
refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget;
|
||||
|
||||
if (uDynamicsOnly > 0.5) {
|
||||
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
|
||||
float dynamicsAlpha =
|
||||
clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82);
|
||||
gl_FragColor = vec4(refracted, dynamicsAlpha);
|
||||
return;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(
|
||||
refracted,
|
||||
clamp(
|
||||
@@ -1120,6 +1173,8 @@ function collectGlassOpticalSurfaceDescriptors(
|
||||
viewportHeight: number,
|
||||
appearance: ThemeCustomizerGlassAppearance,
|
||||
surfaceSpace: GlassPresentationSpace | 'all' = 'all',
|
||||
includeOutsideViewport = false,
|
||||
collectedElements?: HTMLElement[],
|
||||
) {
|
||||
const candidates: Array<GlassOpticalSurfaceDescriptor & { visibleArea: number }> = []
|
||||
const seen = new Set<HTMLElement>()
|
||||
@@ -1134,6 +1189,7 @@ function collectGlassOpticalSurfaceDescriptors(
|
||||
if (seen.has(element)) continue
|
||||
seen.add(element)
|
||||
if (element.closest('.v-overlay')) continue
|
||||
collectedElements?.push(element)
|
||||
|
||||
const bounds = element.getBoundingClientRect()
|
||||
if (!isVisibleSurface(element, bounds)) continue
|
||||
@@ -1144,7 +1200,7 @@ function collectGlassOpticalSurfaceDescriptors(
|
||||
const bottom = Math.min(viewportHeight, bounds.bottom)
|
||||
const visibleHeight = Math.max(0, bottom - top)
|
||||
const visibleWidth = Math.max(0, right - left)
|
||||
if (visibleWidth < 24 || visibleHeight < 24) continue
|
||||
if (!includeOutsideViewport && (visibleWidth < 24 || visibleHeight < 24)) continue
|
||||
const coordinateOffsetX = resolvedSpace === 'scroll' ? window.scrollX : 0
|
||||
const coordinateOffsetY = resolvedSpace === 'scroll' ? window.scrollY : 0
|
||||
|
||||
@@ -1189,6 +1245,24 @@ function collectGlassOpticalSurfaceDescriptors(
|
||||
return selected
|
||||
}
|
||||
|
||||
/** 从已测量的 presentation 坐标中选择当前视口可见表面,不触发 DOM 布局读取。 */
|
||||
function selectVisibleGlassOpticalSurfaceDescriptors(
|
||||
surfaces: GlassOpticalSurfaceDescriptor[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
surfaceSpace: GlassPresentationSpace,
|
||||
) {
|
||||
const viewportX = surfaceSpace === 'scroll' ? window.scrollX : 0
|
||||
const viewportY = surfaceSpace === 'scroll' ? window.scrollY : 0
|
||||
|
||||
return surfaces.filter(({ rect }) => {
|
||||
const visibleWidth = Math.min(viewportX + viewportWidth, rect.x + rect.width) - Math.max(viewportX, rect.x)
|
||||
const visibleHeight = Math.min(viewportY + viewportHeight, rect.y + rect.height) - Math.max(viewportY, rect.y)
|
||||
|
||||
return visibleWidth >= 24 && visibleHeight >= 24
|
||||
})
|
||||
}
|
||||
|
||||
/** 将活动界面中的高价值材质面集中转换为 renderer 矩形预算。 */
|
||||
export function collectGlassOpticalRects(
|
||||
viewportWidth: number,
|
||||
@@ -1237,6 +1311,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let activeFrostedTarget: WebGLRenderTarget | null = null
|
||||
let activeTextureHeight = 1
|
||||
let activeTextureWidth = 1
|
||||
let activeHasWallpaperTexture = false
|
||||
let activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
let previousTexture: Texture | null = null
|
||||
let previousFrostedTarget: WebGLRenderTarget | null = null
|
||||
@@ -1268,7 +1343,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let lastInteractionAt = 0
|
||||
let lastInteractionFrameAt = 0
|
||||
let lastPointerAt = 0
|
||||
let lastTrailAt = 0
|
||||
let lastTrailAt = Number.NEGATIVE_INFINITY
|
||||
let lastPointerX = window.innerWidth * 0.5
|
||||
let lastPointerY = window.innerHeight * 0.5
|
||||
let pointerTargetX = 0.5
|
||||
@@ -1283,7 +1358,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let presentationResizeTimer: number | null = null
|
||||
let scrollAnimationFrame: number | null = null
|
||||
let scrollDirty = false
|
||||
let scrollSurfaceRefreshPending = false
|
||||
let scrollPresentationRestoreTimer: number | null = null
|
||||
let scrollWallpaperSamplingSuppressed = false
|
||||
let scrollFrameCommitted = false
|
||||
let scrollGeometryRefreshPending = false
|
||||
let scrollLateGeometryCommitted = false
|
||||
let scrollSurfaceStabilityPending = false
|
||||
let scrollStableFrameCount = 0
|
||||
let lastRenderedScrollX = window.scrollX
|
||||
let lastRenderedScrollY = window.scrollY
|
||||
@@ -1291,8 +1371,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let surfaceMutationObserver: MutationObserver | null = null
|
||||
let observedSurfaces: HTMLElement[] = []
|
||||
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
|
||||
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
||||
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
||||
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
|
||||
let activeSurface: HTMLElement | null = null
|
||||
let activeInteractionClip: HTMLElement | null = null
|
||||
let outgoingSurface: HTMLElement | null = null
|
||||
@@ -1308,6 +1390,59 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||
const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache()
|
||||
|
||||
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
||||
function syncWallpaperSamplingMode() {
|
||||
if (!resources) return
|
||||
|
||||
resources.uniforms.uHasWallpaperTexture.value =
|
||||
activeHasWallpaperTexture && !(presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed) ? 1 : 0
|
||||
}
|
||||
|
||||
function clearScrollPresentationRestoreTimer() {
|
||||
if (scrollPresentationRestoreTimer === null) return
|
||||
|
||||
window.clearTimeout(scrollPresentationRestoreTimer)
|
||||
scrollPresentationRestoreTimer = null
|
||||
}
|
||||
|
||||
function finishNativeScrollPresentation(timestamp = performance.now()) {
|
||||
clearScrollPresentationRestoreTimer()
|
||||
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed) return
|
||||
|
||||
scrollWallpaperSamplingSuppressed = false
|
||||
syncWallpaperSamplingMode()
|
||||
renderFrame(timestamp, false)
|
||||
document.documentElement.removeAttribute('data-glass-scroll-presentation')
|
||||
}
|
||||
|
||||
function beginNativeScrollPresentation() {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
|
||||
clearScrollPresentationRestoreTimer()
|
||||
scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180)
|
||||
if (scrollWallpaperSamplingSuppressed) return
|
||||
|
||||
scrollWallpaperSamplingSuppressed = true
|
||||
syncWallpaperSamplingMode()
|
||||
document.documentElement.dataset.glassScrollPresentation = 'native'
|
||||
renderFrame(performance.now(), false)
|
||||
}
|
||||
|
||||
function handleScrollIntent(event: Event) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName))
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) return
|
||||
}
|
||||
|
||||
beginNativeScrollPresentation()
|
||||
}
|
||||
|
||||
function updateRendererState(value: GlassRendererState) {
|
||||
if (options.syncDocumentState === false) {
|
||||
state.value = value
|
||||
@@ -1383,8 +1518,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (scrollAnimationFrame !== null) cancelAnimationFrame(scrollAnimationFrame)
|
||||
scrollAnimationFrame = null
|
||||
scrollDirty = false
|
||||
scrollSurfaceRefreshPending = false
|
||||
scrollFrameCommitted = false
|
||||
scrollGeometryRefreshPending = false
|
||||
scrollLateGeometryCommitted = false
|
||||
scrollSurfaceStabilityPending = false
|
||||
scrollStableFrameCount = 0
|
||||
clearScrollPresentationRestoreTimer()
|
||||
}
|
||||
|
||||
function cancelWallpaperTransitionFrame() {
|
||||
@@ -1674,20 +1813,22 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
const presentation = getCommittedPresentationSize()
|
||||
const interactionClipRect =
|
||||
activeInteractionClip?.isConnected && activeSurface?.contains(activeInteractionClip)
|
||||
? getElementPresentationRect(activeInteractionClip)
|
||||
: null
|
||||
const normalizedInteractionClip = interactionClipRect
|
||||
? normalizeGlassOpticalRect(interactionClipRect, presentation.width, presentation.height)
|
||||
: null
|
||||
if (normalizedInteractionClip) {
|
||||
resources.uniforms.uInteractionRect.value.set(...normalizedInteractionClip.rect)
|
||||
resources.uniforms.uInteractionRadii.value.set(...normalizedInteractionClip.radii)
|
||||
resources.uniforms.uHasInteractionClip.value = 1
|
||||
} else {
|
||||
resources.uniforms.uHasInteractionClip.value = 0
|
||||
const normalizedInteractionClips = interactionClips.map(clip => {
|
||||
const rect =
|
||||
clip.key === activeInteractionClip && clip.key.isConnected ? getElementPresentationRect(clip.key) : clip.rect
|
||||
|
||||
return normalizeGlassOpticalRect(rect, presentation.width, presentation.height)
|
||||
})
|
||||
const uniformInteractionRects = resources.uniforms.uInteractionRects.value
|
||||
const uniformInteractionRadii = resources.uniforms.uInteractionRadii.value
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const clip = normalizedInteractionClips[index]
|
||||
const rect = clip?.rect ?? [0, 0, 0, 0]
|
||||
const radii = clip?.radii ?? [0, 0, 0, 0]
|
||||
uniformInteractionRects[index].set(rect[0], rect[1], rect[2], rect[3])
|
||||
uniformInteractionRadii[index].set(radii[0], radii[1], radii[2], radii[3])
|
||||
}
|
||||
resources.uniforms.uInteractionRectCount.value = normalizedInteractionClips.length
|
||||
const normalized = surfaceSlots.map(slot =>
|
||||
normalizeGlassOpticalRect(slot.rect, presentation.width, presentation.height),
|
||||
)
|
||||
@@ -1724,14 +1865,54 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
resources.uniforms.uRectCount.value = normalized.length
|
||||
}
|
||||
|
||||
function updateSurfaceUniforms(timestamp = performance.now(), scheduleRender = true) {
|
||||
if (!resources) return
|
||||
/**
|
||||
* 材质父表面可折叠多个交互卡片;共享动态场只在最终输出时按真实卡片边界裁剪。
|
||||
* 活动卡优先占用固定预算,其余可见卡片继续消费同一时序场。
|
||||
*/
|
||||
function updateInteractionClips() {
|
||||
const seen = new Set<HTMLElement>()
|
||||
const candidates: GlassOpticalSurfaceDescriptor[] = []
|
||||
const append = (element: HTMLElement, mode: GlassOpticalSurfaceMode) => {
|
||||
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
|
||||
|
||||
const rect = getElementPresentationRect(element)
|
||||
if (rect.width < 24 || rect.height < 24) return
|
||||
|
||||
seen.add(element)
|
||||
candidates.push({ key: element, mode, rect })
|
||||
}
|
||||
|
||||
for (const slot of surfaceSlots) {
|
||||
const mode = slot.mode ?? 'dynamic'
|
||||
if (mode === 'static-material') continue
|
||||
|
||||
const nestedClips = [
|
||||
...(slot.key.matches(INTERACTION_CLIP_SELECTOR) ? [slot.key] : []),
|
||||
...slot.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR),
|
||||
]
|
||||
if (nestedClips.length > 0) {
|
||||
nestedClips.forEach(clip => append(clip, mode))
|
||||
} else {
|
||||
append(slot.key, mode)
|
||||
}
|
||||
}
|
||||
|
||||
const activeIndex = activeInteractionClip
|
||||
? candidates.findIndex(candidate => candidate.key === activeInteractionClip)
|
||||
: -1
|
||||
if (activeIndex > 0) candidates.unshift(...candidates.splice(activeIndex, 1))
|
||||
const maxCount =
|
||||
window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||
interactionClips = candidates.slice(0, maxCount)
|
||||
}
|
||||
|
||||
/** 从缓存的 document-space 几何选择当前可见表面并提交 shader slot。 */
|
||||
function updateVisibleSurfaceUniforms(timestamp: number) {
|
||||
const viewportWidth = window.innerWidth
|
||||
availableSurfaces = collectGlassOpticalSurfaceDescriptors(
|
||||
availableSurfaces = selectVisibleGlassOpticalSurfaceDescriptors(
|
||||
surfaceRegistry,
|
||||
viewportWidth,
|
||||
window.innerHeight,
|
||||
toValue(options.appearance),
|
||||
presentationSpace,
|
||||
)
|
||||
const availableKeys = new Set(availableSurfaces.map(surface => surface.key))
|
||||
@@ -1748,19 +1929,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
activeSurface ?? undefined,
|
||||
outgoingSurface ?? undefined,
|
||||
)
|
||||
updateInteractionClips()
|
||||
writeSurfaceUniforms(timestamp)
|
||||
}
|
||||
|
||||
const nextObservedSurfaces = Array.from(
|
||||
new Set(
|
||||
SURFACE_SELECTORS.filter(
|
||||
({ selector, space }) => getSurfacePresentationSpace(selector, space) === presentationSpace,
|
||||
).flatMap(({ selector }) =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(selector)).filter(
|
||||
element => !element.closest('.v-overlay'),
|
||||
),
|
||||
),
|
||||
),
|
||||
function updateSurfaceUniforms(timestamp = performance.now(), scheduleRender = true) {
|
||||
if (!resources) return
|
||||
|
||||
const viewportWidth = window.innerWidth
|
||||
const nextObservedSurfaces: HTMLElement[] = []
|
||||
surfaceRegistry = collectGlassOpticalSurfaceDescriptors(
|
||||
viewportWidth,
|
||||
window.innerHeight,
|
||||
toValue(options.appearance),
|
||||
presentationSpace,
|
||||
true,
|
||||
nextObservedSurfaces,
|
||||
)
|
||||
updateVisibleSurfaceUniforms(timestamp)
|
||||
|
||||
const observedSurfacesChanged =
|
||||
nextObservedSurfaces.length !== observedSurfaces.length ||
|
||||
nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index])
|
||||
@@ -1772,7 +1959,31 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (scheduleRender) scheduleFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动事务内的几何失效共用一个稳定尾帧。
|
||||
* 虚拟列表若在当前滚动帧提交后才挂载节点,必须在本次绘制前同步新蒙版。
|
||||
*/
|
||||
function queueScrollGeometryRefresh(stabilize: boolean) {
|
||||
if (presentationSpace !== 'scroll' || scrollAnimationFrame === null || !resources) return false
|
||||
|
||||
scrollSurfaceStabilityPending ||= stabilize
|
||||
scrollStableFrameCount = 0
|
||||
if (!scrollFrameCommitted) {
|
||||
scrollGeometryRefreshPending = true
|
||||
return true
|
||||
}
|
||||
if (scrollLateGeometryCommitted) return true
|
||||
|
||||
scrollLateGeometryCommitted = true
|
||||
scrollGeometryRefreshPending = false
|
||||
const timestamp = performance.now()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
renderFrame(timestamp, false)
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleSurfaceUpdate() {
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceUpdateFrame !== null || !resources) return
|
||||
|
||||
surfaceUpdateFrame = requestAnimationFrame(timestamp => {
|
||||
@@ -1785,6 +1996,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
|
||||
function scheduleSurfaceStabilityUpdate() {
|
||||
if (queueScrollGeometryRefresh(true)) return
|
||||
surfaceStabilityPass = 0
|
||||
surfaceStableFrameCount = 0
|
||||
lastSurfaceGeometrySignature = ''
|
||||
@@ -1877,6 +2089,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (presentationChanged && commitActivePagePresentation()) return
|
||||
if (presentationChanged) schedulePresentationResizeUpdate()
|
||||
if (!entries.some(entry => entry.target !== presentationRoot)) return
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
|
||||
const timestamp = performance.now()
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
@@ -1885,6 +2098,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
|
||||
function scheduleSurfaceTransformFrame() {
|
||||
if (queueScrollGeometryRefresh(false)) return
|
||||
if (surfaceTransformFrame !== null || !resources) return
|
||||
|
||||
surfaceTransformFrame = requestAnimationFrame(timestamp => {
|
||||
@@ -2137,6 +2351,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
*/
|
||||
function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) {
|
||||
if (activeSurface === surface) {
|
||||
updateInteractionClips()
|
||||
writeSurfaceUniforms(timestamp)
|
||||
return false
|
||||
}
|
||||
@@ -2145,15 +2360,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
outgoingSurface = reducedMotion || surfaceAlreadyHasSlot ? null : activeSurface
|
||||
activeSurface = surface
|
||||
surfaceTransitionStartedAt = timestamp
|
||||
lastTrailAt = Number.NEGATIVE_INFINITY
|
||||
pendingFlowInjection = 0
|
||||
if (resources) {
|
||||
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
|
||||
}
|
||||
if (flowResources) {
|
||||
flowResources.uniforms.uDecay.value = 0
|
||||
flowResources.uniforms.uInjection.value = 0
|
||||
}
|
||||
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
|
||||
surfaceSlots,
|
||||
@@ -2162,6 +2368,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
activeSurface,
|
||||
outgoingSurface ?? undefined,
|
||||
)
|
||||
updateInteractionClips()
|
||||
writeSurfaceUniforms(timestamp)
|
||||
|
||||
return true
|
||||
@@ -2236,11 +2443,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
function resetInteractionState() {
|
||||
pendingFlowInjection = 0
|
||||
lastInteractionFrameAt = 0
|
||||
lastTrailAt = Number.NEGATIVE_INFINITY
|
||||
activeInteractionClip = null
|
||||
interactionClips = []
|
||||
if (!resources) return
|
||||
|
||||
snapPointer(pointerTargetX, pointerTargetY)
|
||||
resources.uniforms.uHasInteractionClip.value = 0
|
||||
resources.uniforms.uInteractionRectCount.value = 0
|
||||
resources.uniforms.uMotion.value = 0
|
||||
resources.uniforms.uPointerVelocity.value.set(0, 0)
|
||||
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
|
||||
@@ -2325,19 +2534,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
activeInteractionClip = interactionTarget.clip
|
||||
const surfaceChanged = activateInteractionSurface(interactionTarget.surface.key, timestamp, reducedMotion)
|
||||
if (interactionClipChanged && !surfaceChanged) {
|
||||
lastTrailAt = Number.NEGATIVE_INFINITY
|
||||
pendingFlowInjection = 0
|
||||
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
|
||||
if (flowResources) {
|
||||
flowResources.uniforms.uDecay.value = 0
|
||||
flowResources.uniforms.uInjection.value = 0
|
||||
}
|
||||
updateInteractionClips()
|
||||
writeSurfaceUniforms(timestamp)
|
||||
}
|
||||
const restartsWake = surfaceChanged || interactionClipChanged || !interactionAnimating
|
||||
const startsInteraction = !interactionAnimating
|
||||
const hasTrailAnchor = Number.isFinite(lastTrailAt)
|
||||
const normalizedX = point.x / Math.max(presentation.width, 1)
|
||||
const normalizedY = 1 - point.y / Math.max(presentation.height, 1)
|
||||
if (surfaceChanged) {
|
||||
if (startsInteraction && !hasTrailAnchor) {
|
||||
snapPointer(normalizedX, normalizedY)
|
||||
updateTrail(point.x, point.y, timestamp, normalizedX, normalizedY)
|
||||
} else {
|
||||
@@ -2352,7 +2556,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
wakeDirection,
|
||||
scaledVelocity,
|
||||
Math.hypot(scaledVelocity.x, scaledVelocity.y),
|
||||
restartsWake,
|
||||
startsInteraction,
|
||||
)
|
||||
resources.uniforms.uPointerVelocity.value.set(scaledVelocity.x, scaledVelocity.y)
|
||||
resources.uniforms.uWakeDirection.value.set(wakeDirection.x, wakeDirection.y)
|
||||
@@ -2421,6 +2625,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
)
|
||||
snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1))
|
||||
updateTrail(
|
||||
point.x,
|
||||
point.y,
|
||||
timestamp,
|
||||
point.x / Math.max(presentation.width, 1),
|
||||
1 - point.y / Math.max(presentation.height, 1),
|
||||
)
|
||||
scheduleFrame()
|
||||
}
|
||||
}
|
||||
@@ -2456,12 +2667,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
handleTouchEnd(event as TouchEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个滚动帧原子提交采样坐标和可见表面;稳定尾帧只负责确认 compositor 已停止移动。
|
||||
* 嵌套滚动容器不会改变 window scroll,因此表面几何不能延迟到 scrollend 才刷新。
|
||||
*/
|
||||
/** 文档滚动只更新采样坐标和缓存可见性;嵌套滚动仍在首帧刷新受影响的表面几何。 */
|
||||
function renderScrollFrame(timestamp: number) {
|
||||
scrollAnimationFrame = null
|
||||
scrollFrameCommitted = false
|
||||
scrollLateGeometryCommitted = false
|
||||
if (
|
||||
presentationSpace !== 'scroll' ||
|
||||
!resources ||
|
||||
@@ -2481,15 +2691,34 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
lastRenderedScrollX = scrollX
|
||||
lastRenderedScrollY = scrollY
|
||||
resources.uniforms.uScrollOffset.value.set(scrollX, scrollY)
|
||||
const shouldRefreshSurfaces = scrollSurfaceRefreshPending || receivedScrollEvent || coordinatesChanged
|
||||
scrollSurfaceRefreshPending = false
|
||||
if (shouldRefreshSurfaces) updateSurfaceUniforms(timestamp, false)
|
||||
if (scrollGeometryRefreshPending) {
|
||||
scrollGeometryRefreshPending = false
|
||||
updateSurfaceUniforms(timestamp, false)
|
||||
} else if (receivedScrollEvent || coordinatesChanged) {
|
||||
updateVisibleSurfaceUniforms(timestamp)
|
||||
}
|
||||
|
||||
scrollStableFrameCount = receivedScrollEvent || coordinatesChanged ? 0 : scrollStableFrameCount + 1
|
||||
if (!interactionAnimating) renderFrame(timestamp, false)
|
||||
scrollFrameCommitted = true
|
||||
if (transformingSurfaces.size > 0) {
|
||||
if (timestamp < surfaceTransformTrackingDeadline) {
|
||||
scrollGeometryRefreshPending = true
|
||||
scrollStableFrameCount = 0
|
||||
} else {
|
||||
transformingSurfaces.clear()
|
||||
scrollSurfaceStabilityPending = true
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollStableFrameCount < SCROLL_STABLE_TAIL_FRAMES) {
|
||||
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
|
||||
} else {
|
||||
finishNativeScrollPresentation(timestamp)
|
||||
if (scrollSurfaceStabilityPending) {
|
||||
scrollSurfaceStabilityPending = false
|
||||
scheduleSurfaceStabilityUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2499,20 +2728,54 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (presentationSpace === 'scroll' && resources) {
|
||||
scrollDirty = true
|
||||
scrollSurfaceRefreshPending = true
|
||||
scheduleScrollFrame()
|
||||
return
|
||||
function handleScroll(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
|
||||
beginNativeScrollPresentation()
|
||||
scrollFrameCommitted = false
|
||||
scrollLateGeometryCommitted = false
|
||||
const target = event.target
|
||||
const isDocumentScroll =
|
||||
!(target instanceof Element) || target === document.documentElement || target === document.body
|
||||
if (!isDocumentScroll) {
|
||||
if (!(target instanceof Element) || !observedSurfaces.some(surface => target.contains(surface))) return
|
||||
scrollGeometryRefreshPending = true
|
||||
} else {
|
||||
resources.uniforms.uScrollOffset.value.set(window.scrollX, window.scrollY)
|
||||
}
|
||||
|
||||
scrollDirty = true
|
||||
scheduleScrollFrame()
|
||||
if (surfaceUpdateFrame !== null) {
|
||||
cancelAnimationFrame(surfaceUpdateFrame)
|
||||
surfaceUpdateFrame = null
|
||||
queueScrollGeometryRefresh(false)
|
||||
}
|
||||
if (surfaceStabilityFrame !== null) {
|
||||
cancelAnimationFrame(surfaceStabilityFrame)
|
||||
surfaceStabilityFrame = null
|
||||
queueScrollGeometryRefresh(true)
|
||||
}
|
||||
if (surfaceTransformFrame !== null) {
|
||||
cancelAnimationFrame(surfaceTransformFrame)
|
||||
surfaceTransformFrame = null
|
||||
queueScrollGeometryRefresh(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleScrollEnd() {
|
||||
function handleScrollEnd(event: Event) {
|
||||
if (presentationSpace !== 'scroll' || !resources) return
|
||||
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target !== document.documentElement &&
|
||||
target !== document.body &&
|
||||
observedSurfaces.some(surface => target.contains(surface))
|
||||
) {
|
||||
scrollGeometryRefreshPending = true
|
||||
}
|
||||
scrollDirty = false
|
||||
scrollSurfaceRefreshPending = true
|
||||
scrollStableFrameCount = 0
|
||||
scheduleScrollFrame()
|
||||
}
|
||||
@@ -2521,6 +2784,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
function pauseRenderer() {
|
||||
cancelScheduledFrame()
|
||||
cancelScrollFrame()
|
||||
finishNativeScrollPresentation()
|
||||
cancelWallpaperTransitionFrame()
|
||||
cancelSurfaceTransformFrame()
|
||||
interactionAnimating = false
|
||||
@@ -2671,6 +2935,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
||||
window.addEventListener('transitioncancel', handleSurfaceTransitionEnd, { capture: true, passive: true })
|
||||
if (presentationSpace === 'scroll') {
|
||||
window.addEventListener('wheel', handleScrollIntent, { capture: true, passive: true })
|
||||
window.addEventListener('touchmove', handleScrollIntent, { capture: true, passive: true })
|
||||
window.addEventListener('keydown', handleScrollIntent, { capture: true })
|
||||
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
|
||||
window.addEventListener('scrollend', handleScrollEnd, { passive: true })
|
||||
}
|
||||
@@ -2693,6 +2960,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
|
||||
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
|
||||
if (presentationSpace === 'scroll') {
|
||||
window.removeEventListener('wheel', handleScrollIntent, true)
|
||||
window.removeEventListener('touchmove', handleScrollIntent, true)
|
||||
window.removeEventListener('keydown', handleScrollIntent, true)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('scrollend', handleScrollEnd)
|
||||
}
|
||||
@@ -2731,8 +3001,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
surfaceMutationObserver?.disconnect()
|
||||
surfaceMutationObserver = null
|
||||
observedSurfaces = []
|
||||
surfaceRegistry = []
|
||||
availableSurfaces = []
|
||||
surfaceSlots = []
|
||||
interactionClips = []
|
||||
activeSurface = null
|
||||
activeInteractionClip = null
|
||||
outgoingSurface = null
|
||||
@@ -2740,6 +3012,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
wakeDirection = { x: 0, y: -1 }
|
||||
document.documentElement.removeAttribute('data-glass-wallpaper-loading')
|
||||
activeTouchIdentifier = null
|
||||
if (presentationSpace === 'scroll') {
|
||||
scrollWallpaperSamplingSuppressed = false
|
||||
document.documentElement.removeAttribute('data-glass-scroll-presentation')
|
||||
}
|
||||
lastPointerX = window.innerWidth * 0.5
|
||||
lastPointerY = window.innerHeight * 0.5
|
||||
pointerTargetX = 0.5
|
||||
@@ -2763,6 +3039,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
activeFrostedTarget = null
|
||||
activeTextureHeight = 1
|
||||
activeTextureWidth = 1
|
||||
activeHasWallpaperTexture = false
|
||||
activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
|
||||
activeWallpaperUrl.value = ''
|
||||
activeWallpaperRevision.value = 0
|
||||
@@ -2893,7 +3170,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
syncCoverScale()
|
||||
}
|
||||
|
||||
resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0
|
||||
activeHasWallpaperTexture = hasWallpaperTexture
|
||||
syncWallpaperSamplingMode()
|
||||
resources.uniforms.uHasFrostedTexture.value = hasWallpaperTexture && frostedTarget ? 1 : 0
|
||||
}
|
||||
|
||||
@@ -3124,7 +3402,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const rollback = {
|
||||
activatedRevision: revision,
|
||||
activatedUrl: url,
|
||||
previousHasWallpaperTexture: resources?.uniforms.uHasWallpaperTexture.value === 1,
|
||||
previousHasWallpaperTexture: activeHasWallpaperTexture,
|
||||
previousPreparationKey: activeWallpaperPreparationKey.value,
|
||||
previousRevision: activeWallpaperRevision.value,
|
||||
previousUrl: activeWallpaperUrl.value,
|
||||
@@ -3203,7 +3481,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
resources.uniforms.uWallpaperExposure.value = activeWallpaperExposure
|
||||
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
|
||||
resources.uniforms.uTextureMix.value = 1
|
||||
resources.uniforms.uHasWallpaperTexture.value = rollback.previousHasWallpaperTexture ? 1 : 0
|
||||
activeHasWallpaperTexture = rollback.previousHasWallpaperTexture
|
||||
syncWallpaperSamplingMode()
|
||||
resources.uniforms.uHasFrostedTexture.value = rollback.previousHasWallpaperTexture && activeFrostedTarget ? 1 : 0
|
||||
syncCoverScale()
|
||||
resetInteractionState()
|
||||
@@ -3254,14 +3533,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
uBackgroundVisibility: { value: materialResponse.backgroundVisibility },
|
||||
uCoverScale: { value: new three.Vector2(1, 1) },
|
||||
uDeformationStrength: { value: getDeformationStrengthScale() },
|
||||
uDynamicsOnly: { value: presentationSpace === 'scroll' ? 1 : 0 },
|
||||
uFlowTexture: { value: null },
|
||||
uFlowStrength: { value: getFlowStrengthScale() },
|
||||
uHasFlowTexture: { value: 0 },
|
||||
uHasFrostedTexture: { value: 0 },
|
||||
uHasInteractionClip: { value: 0 },
|
||||
uHasWallpaperTexture: { value: 0 },
|
||||
uInteractionRadii: { value: new Vector4Class() },
|
||||
uInteractionRect: { value: new Vector4Class() },
|
||||
uInteractionRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||
uInteractionRectCount: { value: 0 },
|
||||
uInteractionRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||
uMotion: { value: 0 },
|
||||
uMotionExpansion: { value: getMotionExpansion() },
|
||||
uMaxRefractionPixels: { value: getMaxRefractionPixels() },
|
||||
|
||||
@@ -134,6 +134,20 @@ const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||
|
||||
let themeApplyVersion = 0
|
||||
|
||||
type DefaultGlassCustomizerSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTransmissionStrength'
|
||||
| 'glassTranslationStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
>
|
||||
|
||||
/** 判断当前代码是否运行在浏览器环境。 */
|
||||
function isBrowser() {
|
||||
return typeof window !== 'undefined'
|
||||
@@ -153,9 +167,11 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
||||
return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto'
|
||||
}
|
||||
|
||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
/** 从预设矩阵生成指定质量的清透自然玻璃默认设置。 */
|
||||
export function getDefaultGlassCustomizerSettings(
|
||||
quality: ThemeCustomizerGlassQuality = defaultGlassQuality,
|
||||
): DefaultGlassCustomizerSettings {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', quality, 'natural')
|
||||
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
@@ -163,11 +179,18 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassQuality: quality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: glassParameters.translation,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
return {
|
||||
...getDefaultGlassCustomizerSettings(),
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -608,18 +631,8 @@ export function cancelGlassPreview() {
|
||||
|
||||
/** 判断当前主题定制设置是否仍为默认值。 */
|
||||
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
const defaults = normalizeThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
...getDefaultGlassCustomizerSettings(),
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -849,19 +862,8 @@ export function useThemeCustomizer() {
|
||||
|
||||
/** 将主题定制器恢复到默认设置。 */
|
||||
async function resetSettings() {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
|
||||
await updateSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: glassParameters.translation,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
...getDefaultGlassCustomizerSettings(),
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
|
||||
@@ -64,4 +64,21 @@ describe('glass overlay material styles', () => {
|
||||
expect(layerRule).not.toMatch(/transition\s*:/)
|
||||
expect(styles).toMatch(/\[data-glass-renderer-state='ready'\]\s*\.glass-optical-layer\s*\{\s*opacity:\s*1;/)
|
||||
})
|
||||
|
||||
it('keeps the native scroll backplate stable while suspending only GPU dynamics', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('--glass-native-surface-backdrop-filter')
|
||||
expect(styles).toContain('blur(calc(16px * var(--glass-frost-blur-scale, 1))) saturate(162%)')
|
||||
expect(styles).toContain('blur(calc(10px * var(--glass-frost-blur-scale, 1))) saturate(154%)')
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-scroll-presentation='native'\][\s\S]*?\.glass-optical-layer--scroll\s*\{\s*opacity:\s*0\s*!important;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-renderer-state='ready'\][\s\S]*?\.app-hover-lift-card:not\(\.media-card--image-loaded\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
expect(styles).not.toMatch(
|
||||
/\[data-glass-scroll-presentation='native'\][\s\S]*?:where\([\s\S]*?--glass-native-surface-backdrop-filter/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,7 +42,8 @@ html[data-theme='glass'] {
|
||||
--glass-control-prominent-focus-shadow:
|
||||
0 0 0 2px rgba(var(--v-theme-primary), 16%), inset 0 1px 0 rgba(255, 255, 255, 24%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 16%);
|
||||
--glass-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||
--glass-native-surface-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||
--glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter);
|
||||
--glass-raised-backdrop-filter: brightness(var(--glass-transmission-brightness));
|
||||
--glass-fixed-shell-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-control-backdrop-filter: none;
|
||||
@@ -200,8 +201,9 @@ html[data-theme='glass'] {
|
||||
--glass-control-prominent-focus-shadow:
|
||||
0 0 0 2px rgba(var(--v-theme-primary), 17%), inset 0 1px 0 rgba(255, 255, 255, 34%),
|
||||
inset 0 -1px 0 rgba(2, 6, 16, 18%);
|
||||
--glass-surface-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
|
||||
--glass-native-surface-backdrop-filter: blur(var(--glass-blur-surface)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
--glass-surface-backdrop-filter: var(--glass-native-surface-backdrop-filter);
|
||||
--glass-raised-backdrop-filter: blur(var(--glass-blur-raised)) saturate(var(--glass-saturate))
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
// 固定全高导航限制采样半径,避免页面重绘扩大其 backdrop 栅格化区域。
|
||||
@@ -1215,12 +1217,6 @@ html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass
|
||||
-webkit-backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||
}
|
||||
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 磨砂材质的散射由 renderer 统一完成,避免 CSS 再次模糊折射结果。
|
||||
@@ -1234,14 +1230,43 @@ html[data-glass-appearance='frosted']:is(
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动表面始终由原生 backdrop 持有壁纸基底;GPU 层只叠加局部动态折射。
|
||||
html:is(
|
||||
[data-glass-quality='balanced'],
|
||||
[data-glass-quality='high']
|
||||
)[data-glass-renderer-state='ready']
|
||||
body[data-theme='glass'] {
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
.dashboard-grid-item-content
|
||||
> .dashboard-grid-auto-size
|
||||
> .dashboard-grid-content-measure
|
||||
> :first-child
|
||||
> .v-card,
|
||||
[data-glass-optical-surface],
|
||||
.app-hover-lift-card:not(.media-card--image-loaded) {
|
||||
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 实时档缩小原生磨砂核;通透度继续通过 frost scale 控制纹理保留程度。
|
||||
html[data-glass-appearance='frosted'][data-glass-quality='balanced'][data-glass-renderer-state='ready'] {
|
||||
--glass-native-surface-backdrop-filter: blur(calc(16px * var(--glass-frost-blur-scale, 1))) saturate(162%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'][data-glass-quality='high'][data-glass-renderer-state='ready'] {
|
||||
--glass-native-surface-backdrop-filter: blur(calc(10px * var(--glass-frost-blur-scale, 1))) saturate(154%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
}
|
||||
|
||||
// 异步滚动期间只暂停 GPU 动态层;原生壁纸基底不发生材质切换。
|
||||
html[data-glass-scroll-presentation='native'][data-glass-renderer-state='ready'] .glass-optical-layer--scroll {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
// 登录页只保留内容保护表面;折射、方向反射和唯一动态焦散均由共享 renderer 负责。
|
||||
html[data-theme='glass'] body[data-theme='glass'] {
|
||||
.login-card {
|
||||
|
||||
@@ -93,12 +93,12 @@ describe('glass optics geometry', () => {
|
||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||
|
||||
expect(natural).toEqual({
|
||||
deformation: 40,
|
||||
flow: 40,
|
||||
reflection: 35,
|
||||
transmission: 54,
|
||||
translation: 40,
|
||||
transparency: 46,
|
||||
deformation: 48,
|
||||
flow: 48,
|
||||
reflection: 42,
|
||||
transmission: 65,
|
||||
translation: 48,
|
||||
transparency: 50,
|
||||
})
|
||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||
@@ -110,42 +110,42 @@ describe('glass optics geometry', () => {
|
||||
it('keeps every preset dynamic parameter at the approved material calibration', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
css: { natural: { deformation: 48, flow: 48, translation: 48 } },
|
||||
balanced: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 24, flow: 35, translation: 58 },
|
||||
liquid: { deformation: 56, flow: 61, translation: 45 },
|
||||
natural: { deformation: 48, flow: 48, translation: 48 },
|
||||
glide: { deformation: 29, flow: 42, translation: 70 },
|
||||
liquid: { deformation: 67, flow: 73, translation: 54 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 40, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 37, translation: 59 },
|
||||
liquid: { deformation: 59, flow: 64, translation: 46 },
|
||||
natural: { deformation: 48, flow: 48, translation: 48 },
|
||||
glide: { deformation: 31, flow: 44, translation: 71 },
|
||||
liquid: { deformation: 71, flow: 77, translation: 55 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
css: { natural: { deformation: 48, flow: 48, translation: 48 } },
|
||||
balanced: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 26, flow: 35, translation: 56 },
|
||||
liquid: { deformation: 58, flow: 61, translation: 45 },
|
||||
natural: { deformation: 50, flow: 48, translation: 48 },
|
||||
glide: { deformation: 31, flow: 42, translation: 67 },
|
||||
liquid: { deformation: 70, flow: 73, translation: 54 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 42, flow: 40, translation: 40 },
|
||||
glide: { deformation: 27, flow: 37, translation: 58 },
|
||||
liquid: { deformation: 61, flow: 64, translation: 46 },
|
||||
natural: { deformation: 50, flow: 48, translation: 48 },
|
||||
glide: { deformation: 32, flow: 44, translation: 70 },
|
||||
liquid: { deformation: 73, flow: 77, translation: 55 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
|
||||
css: { natural: { deformation: 48, flow: 48, translation: 48 } },
|
||||
balanced: {
|
||||
natural: { deformation: 46, flow: 42, translation: 38 },
|
||||
glide: { deformation: 30, flow: 35, translation: 54 },
|
||||
liquid: { deformation: 62, flow: 61, translation: 42 },
|
||||
natural: { deformation: 55, flow: 50, translation: 46 },
|
||||
glide: { deformation: 36, flow: 42, translation: 65 },
|
||||
liquid: { deformation: 74, flow: 73, translation: 50 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 48, flow: 42, translation: 38 },
|
||||
glide: { deformation: 32, flow: 37, translation: 56 },
|
||||
liquid: { deformation: 66, flow: 64, translation: 43 },
|
||||
natural: { deformation: 58, flow: 50, translation: 46 },
|
||||
glide: { deformation: 38, flow: 44, translation: 67 },
|
||||
liquid: { deformation: 79, flow: 77, translation: 52 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -189,19 +189,19 @@ describe('glass optics geometry', () => {
|
||||
it('uses the approved transparency and transmission matrix for all effective presets', () => {
|
||||
const expected = {
|
||||
clear: {
|
||||
css: { natural: [48, 56] },
|
||||
balanced: { natural: [46, 54], glide: [56, 58], liquid: [51, 51] },
|
||||
high: { natural: [45, 53], glide: [54, 56], liquid: [50, 50] },
|
||||
css: { natural: [52, 67] },
|
||||
balanced: { natural: [50, 65], glide: [60, 70], liquid: [55, 61] },
|
||||
high: { natural: [49, 64], glide: [59, 67], liquid: [54, 60] },
|
||||
},
|
||||
tinted: {
|
||||
css: { natural: [34, 54] },
|
||||
balanced: { natural: [32, 56], glide: [40, 61], liquid: [36, 53] },
|
||||
high: { natural: [30, 54], glide: [38, 59], liquid: [34, 51] },
|
||||
css: { natural: [37, 65] },
|
||||
balanced: { natural: [34, 67], glide: [43, 73], liquid: [39, 64] },
|
||||
high: { natural: [32, 65], glide: [41, 71], liquid: [37, 61] },
|
||||
},
|
||||
frosted: {
|
||||
css: { natural: [31, 50] },
|
||||
balanced: { natural: [29, 52], glide: [40, 56], liquid: [34, 49] },
|
||||
high: { natural: [27, 50], glide: [38, 54], liquid: [32, 47] },
|
||||
css: { natural: [43, 60] },
|
||||
balanced: { natural: [40, 62], glide: [55, 67], liquid: [47, 59] },
|
||||
high: { natural: [37, 60], glide: [53, 65], liquid: [44, 56] },
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -233,20 +233,27 @@ describe('glass optics geometry', () => {
|
||||
})
|
||||
const frostedLow = getGlassMaterialResponse('frosted', 20)
|
||||
expect(frostedLow).toMatchObject({
|
||||
backgroundVisibility: 0.14,
|
||||
frostBlurScale: 1.48,
|
||||
surfaceDensity: 0.96,
|
||||
backgroundVisibility: 0.22,
|
||||
frostBlurScale: 1.384,
|
||||
surfaceDensity: 0.9,
|
||||
})
|
||||
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.1)
|
||||
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.18)
|
||||
const frostedMid = getGlassMaterialResponse('frosted', 50)
|
||||
expect(frostedMid).toMatchObject({
|
||||
backgroundVisibility: 0.52,
|
||||
frostBlurScale: 1.06,
|
||||
surfaceDensity: 0.7,
|
||||
})
|
||||
expect(frostedMid.frostDetailLevel).toBeCloseTo(0.45)
|
||||
expect(getGlassMaterialResponse('frosted', 100)).toMatchObject({
|
||||
backgroundVisibility: 0.88,
|
||||
frostBlurScale: 0.52,
|
||||
frostDetailLevel: 0.9,
|
||||
surfaceDensity: 0.4,
|
||||
backgroundVisibility: 0.98,
|
||||
frostBlurScale: 0.43,
|
||||
frostDetailLevel: 0.975,
|
||||
surfaceDensity: 0.22,
|
||||
})
|
||||
expect(getGlassCssFrostBlur(0)).toEqual({ raised: 84, surface: 64 })
|
||||
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 62, surface: 44 })
|
||||
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 26, surface: 16 })
|
||||
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 50, surface: 36 })
|
||||
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 16, surface: 8 })
|
||||
|
||||
const samples = [0, 10, 20, 35, 50, 60, 70, 78, 85, 92, 100].map(value =>
|
||||
getGlassMaterialResponse('frosted', value),
|
||||
@@ -266,7 +273,7 @@ describe('glass optics geometry', () => {
|
||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||
first.translation = 0
|
||||
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(58)
|
||||
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(70)
|
||||
})
|
||||
|
||||
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
|
||||
|
||||
@@ -153,47 +153,47 @@ type GlassOpticalCapabilityPresets = {
|
||||
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
|
||||
clear: {
|
||||
css: {
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
|
||||
natural: { deformation: 48, flow: 48, reflection: 42, transmission: 67, translation: 48, transparency: 52 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
|
||||
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
|
||||
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
|
||||
natural: { deformation: 48, flow: 48, reflection: 42, transmission: 65, translation: 48, transparency: 50 },
|
||||
glide: { deformation: 29, flow: 42, reflection: 35, transmission: 70, translation: 70, transparency: 60 },
|
||||
liquid: { deformation: 67, flow: 73, reflection: 43, transmission: 61, translation: 54, transparency: 55 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
|
||||
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
|
||||
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
|
||||
natural: { deformation: 48, flow: 48, reflection: 38, transmission: 64, translation: 48, transparency: 49 },
|
||||
glide: { deformation: 31, flow: 44, reflection: 34, transmission: 67, translation: 71, transparency: 59 },
|
||||
liquid: { deformation: 71, flow: 77, reflection: 42, transmission: 60, translation: 55, transparency: 54 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: {
|
||||
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
|
||||
natural: { deformation: 48, flow: 48, reflection: 46, transmission: 65, translation: 48, transparency: 37 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
|
||||
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
|
||||
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
|
||||
natural: { deformation: 50, flow: 48, reflection: 46, transmission: 67, translation: 48, transparency: 34 },
|
||||
glide: { deformation: 31, flow: 42, reflection: 41, transmission: 73, translation: 67, transparency: 43 },
|
||||
liquid: { deformation: 70, flow: 73, reflection: 47, transmission: 64, translation: 54, transparency: 39 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
|
||||
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
|
||||
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
|
||||
natural: { deformation: 50, flow: 48, reflection: 42, transmission: 65, translation: 48, transparency: 32 },
|
||||
glide: { deformation: 32, flow: 44, reflection: 38, transmission: 71, translation: 70, transparency: 41 },
|
||||
liquid: { deformation: 73, flow: 77, reflection: 46, transmission: 61, translation: 55, transparency: 37 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: {
|
||||
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
|
||||
natural: { deformation: 48, flow: 48, reflection: 37, transmission: 60, translation: 48, transparency: 43 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
|
||||
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
|
||||
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
|
||||
natural: { deformation: 55, flow: 50, reflection: 37, transmission: 62, translation: 46, transparency: 40 },
|
||||
glide: { deformation: 36, flow: 42, reflection: 32, transmission: 67, translation: 65, transparency: 55 },
|
||||
liquid: { deformation: 74, flow: 73, reflection: 38, transmission: 59, translation: 50, transparency: 47 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
|
||||
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
|
||||
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
|
||||
natural: { deformation: 58, flow: 50, reflection: 35, transmission: 60, translation: 46, transparency: 37 },
|
||||
glide: { deformation: 38, flow: 44, reflection: 30, transmission: 65, translation: 67, transparency: 53 },
|
||||
liquid: { deformation: 79, flow: 77, reflection: 37, transmission: 56, translation: 52, transparency: 44 },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -240,15 +240,16 @@ const GLASS_RESPONSE_STOPS = [0, 20, 50, 70, 85, 100] as const
|
||||
const GLASS_BACKGROUND_VISIBILITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [0.18, 0.3, 0.58, 0.77, 0.9, 0.96],
|
||||
tinted: [0.08, 0.2, 0.48, 0.7, 0.84, 0.92],
|
||||
frosted: [0.04, 0.14, 0.35, 0.6, 0.78, 0.88],
|
||||
frosted: [0.04, 0.22, 0.52, 0.72, 0.89, 0.98],
|
||||
}
|
||||
const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
|
||||
clear: [1, 0.88, 0.62, 0.42, 0.26, 0.18],
|
||||
tinted: [1, 0.92, 0.72, 0.52, 0.39, 0.3],
|
||||
frosted: [1, 0.96, 0.86, 0.68, 0.5, 0.4],
|
||||
frosted: [1, 0.9, 0.7, 0.52, 0.36, 0.22],
|
||||
}
|
||||
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
|
||||
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.4, 0.18, 0.1] as const
|
||||
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.34, 0.12, 0.04] as const
|
||||
const GLASS_FROSTED_DENSITY = [1, 0.82, 0.55, 0.28, 0.1, 0.025] as const
|
||||
|
||||
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
|
||||
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
|
||||
@@ -269,7 +270,10 @@ function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
|
||||
* 一个通透度输入派生互不混用的材质响应;tone、曝光和透射亮度不在此处计算。
|
||||
*/
|
||||
export function getGlassMaterialResponse(appearance: GlassAppearance, value: unknown): GlassMaterialResponse {
|
||||
const frostDensity = interpolateGlassResponse(value, GLASS_FROST_DENSITY)
|
||||
const frostDensity = interpolateGlassResponse(
|
||||
value,
|
||||
appearance === 'frosted' ? GLASS_FROSTED_DENSITY : GLASS_FROST_DENSITY,
|
||||
)
|
||||
|
||||
return {
|
||||
backgroundVisibility: interpolateGlassResponse(value, GLASS_BACKGROUND_VISIBILITY[appearance]),
|
||||
@@ -283,8 +287,8 @@ export function getGlassMaterialResponse(appearance: GlassAppearance, value: unk
|
||||
/** 标准档磨砂使用独立的 surface/raised 半径锚点,不借用背景亮度制造厚度。 */
|
||||
export function getGlassCssFrostBlur(value: unknown) {
|
||||
return {
|
||||
raised: interpolateGlassResponse(value, [84, 76, 62, 46, 34, 26]),
|
||||
surface: interpolateGlassResponse(value, [64, 58, 44, 30, 22, 16]),
|
||||
raised: interpolateGlassResponse(value, [84, 70, 50, 35, 24, 16]),
|
||||
surface: interpolateGlassResponse(value, [64, 52, 36, 24, 15, 8]),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user