fix(glass): stabilize scroll material and shared dynamics (#598)

This commit is contained in:
InfinityPacer
2026-07-30 14:46:09 +08:00
committed by GitHub
parent 715da1929b
commit d29a008e13
10 changed files with 962 additions and 267 deletions

View File

@@ -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()
})

View File

@@ -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' ||

View File

@@ -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)',

View File

@@ -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)

View File

@@ -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() },

View File

@@ -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',

View File

@@ -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/,
)
})
})

View File

@@ -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 {

View File

@@ -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', () => {

View File

@@ -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]),
}
}