diff --git a/src/App.vue b/src/App.vue index 67072662..7311a984 100644 --- a/src/App.vue +++ b/src/App.vue @@ -27,7 +27,8 @@ import { getDisplayImageUrl } from '@/utils/imageUtils' import { configureApexChartsTheme } from '@/utils/apexCharts' import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus' import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle' -import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation' +import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation' +import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics' const LOGIN_WALLPAPER_ROUTE = '/login' const BACKGROUND_CROSSFADE_DURATION_MS = 1500 @@ -74,6 +75,7 @@ const backgroundImages = ref([]) const activeImageIndex = ref(0) const previousImageIndex = ref(null) const isBackgroundCrossfading = ref(false) +const backgroundCrossfadeStartedAt = ref(0) const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle() const preferredMotion = usePreferredReducedMotion() // 壁纸轮播同时服从应用活动状态与系统动态效果偏好。 @@ -84,16 +86,37 @@ const effectiveGlassSettings = useEffectiveGlassSettings() const isInitialRouteReady = ref(false) const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value) const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) +// 登录专题保持固定光学参数,避免全局用户偏好改变其独立视觉合成。 +const opticalDeformationStrength = computed(() => + isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassDeformationStrength, +) +const opticalFlowStrength = computed(() => + isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassFlowStrength, +) +const opticalReflectionStrength = computed(() => + isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassReflectionStrength, +) +const opticalTransparencyStrength = computed(() => + isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTransparencyStrength, +) +const opticalTranslationStrength = computed(() => + isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTranslationStrength, +) const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value) const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value) const shouldLoadBackgroundImages = computed( () => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value), ) const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '') +const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value)) // 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。 -const activeOpticalBackgroundImage = computed(() => - getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)), -) +const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value)) +const previousOpticalBackgroundImage = computed(() => { + const previousIndex = previousImageIndex.value + if (previousIndex === null) return '' + + return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '') +}) const appWrapperStyle = computed(() => ({ '--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none', })) @@ -102,7 +125,6 @@ const shouldRenderGlassOpticalLayer = computed( isGlassTheme.value && effectiveGlassSettings.value.glassQuality !== 'css' && isInitialRouteReady.value && - !isRenderThrottled.value && Boolean(activeBackgroundImage.value), ) const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue')) @@ -350,6 +372,7 @@ function resetBackgroundCrossfade() { clearBackgroundCrossfadeTimer() previousImageIndex.value = null isBackgroundCrossfading.value = false + backgroundCrossfadeStartedAt.value = 0 } // 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。 @@ -359,6 +382,7 @@ function activateBackgroundImage(nextIndex: number) { clearBackgroundCrossfadeTimer() previousImageIndex.value = activeImageIndex.value isBackgroundCrossfading.value = true + backgroundCrossfadeStartedAt.value = performance.now() activeImageIndex.value = nextIndex backgroundCrossfadeTimer = window.setTimeout(() => { previousImageIndex.value = null @@ -390,11 +414,21 @@ function rotateBackgroundImage() { // 计算下一个图片索引 const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length const requestVersion = ++backgroundRotationVersion + const nextImage = backgroundImages.value[nextIndex] + const opticalImage = + shouldRenderGlassOpticalLayer.value && !isLoginWallpaperRoute.value + ? getOpticalBackgroundImage(nextImage) + : undefined void commitPreloadedBackgroundRotation({ canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion, commit: () => activateBackgroundImage(nextIndex), - preload: () => preloadImage(backgroundImages.value[nextIndex]), + preload: () => + preloadBackgroundRotationImages({ + displayUrl: nextImage, + opticalUrl: opticalImage, + preload: preloadImage, + }), }) } } @@ -679,11 +713,18 @@ onUnmounted(() => { diff --git a/src/components/dialog/GlassSettingsDialog.vue b/src/components/dialog/GlassSettingsDialog.vue index 47e33e40..08e228c6 100644 --- a/src/components/dialog/GlassSettingsDialog.vue +++ b/src/components/dialog/GlassSettingsDialog.vue @@ -7,6 +7,14 @@ import { type ThemeCustomizerGlassAppearance, type ThemeCustomizerGlassQuality, } from '@/composables/useThemeCustomizer' +import { + GLASS_OPTICAL_STRENGTH_MAX, + GLASS_OPTICAL_STRENGTH_MIN, + getAvailableGlassOpticalPresets, + getGlassOpticalPresetParameters, + normalizeGlassOpticalStrength, + type GlassOpticalPreset, +} from '@/utils/glassOptics' import { useI18n } from 'vue-i18n' const props = withDefaults( @@ -26,8 +34,20 @@ const emit = defineEmits<{ const { t } = useI18n() const { settings } = useThemeCustomizer() const draftAppearance = ref(settings.value.glassAppearance) +const draftDeformationStrength = ref(settings.value.glassDeformationStrength) +const draftFlowStrength = ref(settings.value.glassFlowStrength) +const draftPreset = ref(settings.value.glassPreset) const draftQuality = ref(settings.value.glassQuality) +const draftReflectionStrength = ref(settings.value.glassReflectionStrength) +const draftTranslationStrength = ref(settings.value.glassTranslationStrength) +const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength) const isSaving = ref(false) +const usesRealtimeOptics = computed(() => draftQuality.value !== 'css') +const showsDynamicTuning = computed(() => usesRealtimeOptics.value) +const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value)) +const activePreset = computed(() => + availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural', +) const visible = computed({ get: () => props.modelValue, @@ -44,7 +64,13 @@ watch( (value, previous) => { if (value) { draftAppearance.value = settings.value.glassAppearance + draftDeformationStrength.value = settings.value.glassDeformationStrength + draftFlowStrength.value = settings.value.glassFlowStrength + draftPreset.value = settings.value.glassPreset draftQuality.value = settings.value.glassQuality + draftReflectionStrength.value = settings.value.glassReflectionStrength + draftTranslationStrength.value = settings.value.glassTranslationStrength + draftTransparencyStrength.value = settings.value.glassTransparencyStrength } else if (previous) { cancelGlassPreview() } @@ -61,13 +87,23 @@ const appearanceOptions: Array<{ ] const qualityOptions: Array<{ + hint: string label: string value: ThemeCustomizerGlassQuality }> = [ - { label: 'theme.glassQualityCss', value: 'css' }, - { label: 'theme.glassQualityBalanced', value: 'balanced' }, - { label: 'theme.glassQualityHigh', value: 'high' }, + { hint: 'theme.glassQualityCssHint', label: 'theme.glassQualityCss', value: 'css' }, + { hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' }, + { hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' }, ] +const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '') +const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [ + { label: 'theme.glassPresetNatural', value: 'natural' }, + { label: 'theme.glassPresetGlide', value: 'glide' }, + { label: 'theme.glassPresetLiquid', value: 'liquid' }, +] +const visiblePresetOptions = computed(() => + presetOptions.filter(option => availablePresets.value.includes(option.value)), +) /** 仅允许已实现的材质进入待保存设置。 */ function updateAppearance(value: unknown) { @@ -86,16 +122,68 @@ function updateQuality(value: unknown) { previewGlassSettings({ glassQuality: option.value }) } -/** 将当前草稿恢复为玻璃主题默认值并立即预览。 */ -function resetSettings() { - draftAppearance.value = 'clear' - draftQuality.value = 'balanced' +/** 将五个具体参数作为一个预览事务同步,预置只负责生成这些值。 */ +function previewDraftParameters() { previewGlassSettings({ - glassAppearance: draftAppearance.value, - glassQuality: draftQuality.value, + glassDeformationStrength: draftDeformationStrength.value, + glassFlowStrength: draftFlowStrength.value, + glassPreset: draftPreset.value, + glassReflectionStrength: draftReflectionStrength.value, + glassTranslationStrength: draftTranslationStrength.value, + glassTransparencyStrength: draftTransparencyStrength.value, }) } +/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */ +function applyPreset(value: unknown) { + if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return + if (!availablePresets.value.includes(value)) return + + const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value) + draftPreset.value = value + draftDeformationStrength.value = parameters.deformation + draftFlowStrength.value = parameters.flow + draftReflectionStrength.value = parameters.reflection + draftTranslationStrength.value = parameters.translation + draftTransparencyStrength.value = parameters.transparency + previewDraftParameters() +} + +/** 将采样平移限制为 renderer 支持的稳定范围。 */ +function updateTranslationStrength(value: unknown) { + draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) + previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value }) +} + +/** 将局部形变限制为质量档软上限所消费的用户范围。 */ +function updateDeformationStrength(value: unknown) { + draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) + previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value }) +} + +/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */ +function updateFlowStrength(value: unknown) { + draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) + previewGlassSettings({ glassFlowStrength: draftFlowStrength.value }) +} + +/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */ +function updateReflectionStrength(value: unknown) { + draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) + previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value }) +} + +/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */ +function updateTransparencyStrength(value: unknown) { + draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value) + previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value }) +} + +/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */ +function resetSettings() { + applyPreset(activePreset.value) +} + /** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */ async function saveSettings() { if (isSaving.value) return @@ -105,7 +193,13 @@ async function saveSettings() { try { previewGlassSettings({ glassAppearance: draftAppearance.value, + glassDeformationStrength: draftDeformationStrength.value, + glassFlowStrength: draftFlowStrength.value, + glassPreset: draftPreset.value, glassQuality: draftQuality.value, + glassReflectionStrength: draftReflectionStrength.value, + glassTranslationStrength: draftTranslationStrength.value, + glassTransparencyStrength: draftTransparencyStrength.value, }) commitGlassPreview() visible.value = false @@ -171,6 +265,122 @@ onScopeDispose(cancelGlassPreview) {{ t(option.label) }} +

{{ t(qualityHint) }}

+ + +
+
+

{{ t('theme.glassPreset') }}

+
+ + + {{ t(option.label) }} + + +
+ +
+
+

{{ t('theme.glassTransparencyStrength') }}

+ {{ draftTransparencyStrength }}% +
+ + +
+

{{ t('theme.glassReflectionStrength') }}

+ {{ draftReflectionStrength }}% +
+ + +
+
+

{{ t('theme.glassTranslationStrength') }}

+ {{ draftTranslationStrength }}% +
+ + +
+

{{ t('theme.glassDeformationStrength') }}

+ {{ draftDeformationStrength }}% +
+ + +
+

{{ t('theme.glassFlowStrength') }}

+ {{ draftFlowStrength }}% +
+ +
+

+ {{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }} +

@@ -204,8 +414,57 @@ onScopeDispose(cancelGlassPreview) line-height: 1.4; } +.glass-settings-dialog__hint { + margin: 8px 0 0; + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.75rem; + line-height: 1.45; +} + +.glass-settings-dialog__slider-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + + .glass-settings-dialog__label { + margin-block-end: 0; + } + + output { + color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + } +} + +.glass-settings-dialog__slider-header--spaced, +.glass-settings-dialog__slider-header + .glass-settings-dialog__slider-header { + margin-block-start: 18px; +} + +.glass-settings-dialog__live-controls { + margin-block-start: 18px; + + :deep(.v-slider) { + margin-block-start: 4px; + } +} + +.glass-settings-dialog__preset-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + .glass-settings-dialog__label { + margin-block-end: 0; + } +} + .glass-settings-dialog__appearance, -.glass-settings-dialog__quality { +.glass-settings-dialog__quality, +.glass-settings-dialog__preset { display: grid; overflow: hidden; border: 1px solid rgba(var(--v-theme-on-surface), 0.12); @@ -226,6 +485,12 @@ onScopeDispose(cancelGlassPreview) grid-template-columns: repeat(3, minmax(0, 1fr)); } +.glass-settings-dialog__preset { + block-size: 42px !important; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-block-start: 10px; +} + .glass-settings-dialog__appearance-option { block-size: 32px !important; inline-size: 100%; @@ -233,7 +498,8 @@ onScopeDispose(cancelGlassPreview) } .glass-settings-dialog__appearance-option, -.glass-settings-dialog__quality-option { +.glass-settings-dialog__quality-option, +.glass-settings-dialog__preset-option { border: 0 !important; border-radius: 7px !important; box-shadow: none !important; @@ -247,8 +513,13 @@ onScopeDispose(cancelGlassPreview) block-size: 32px !important; } +.glass-settings-dialog__preset-option { + block-size: 32px !important; +} + .glass-settings-dialog__appearance-option:deep(.v-btn--active), -.glass-settings-dialog__quality-option:deep(.v-btn--active) { +.glass-settings-dialog__quality-option:deep(.v-btn--active), +.glass-settings-dialog__preset-option:deep(.v-btn--active) { background-color: rgba(var(--v-theme-primary), 0.14) !important; box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important; } diff --git a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts index 74cd2197..35f492b9 100644 --- a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts +++ b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts @@ -3,11 +3,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue' const slotStub = { template: '
' } +const toggleStub = { + props: ['modelValue'], + template: '
', +} +const sliderStub = { + emits: ['update:modelValue'], + name: 'VSlider', + props: ['disabled', 'modelValue'], + template: + '', +} const mocks = vi.hoisted(() => ({ cancelGlassPreview: vi.fn(), commitGlassPreview: vi.fn(), previewGlassSettings: vi.fn(), + settings: { + value: { + glassAppearance: 'clear', + glassDeformationStrength: 50, + glassFlowStrength: 50, + glassPreset: 'natural', + glassQuality: 'css', + glassReflectionStrength: 50, + glassTranslationStrength: 50, + glassTransparencyStrength: 50, + }, + }, })) vi.mock('@/composables/useThemeCustomizer', () => ({ @@ -15,12 +38,7 @@ vi.mock('@/composables/useThemeCustomizer', () => ({ commitGlassPreview: mocks.commitGlassPreview, previewGlassSettings: mocks.previewGlassSettings, useThemeCustomizer: () => ({ - settings: { - value: { - glassAppearance: 'clear', - glassQuality: 'css', - }, - }, + settings: mocks.settings, }), })) @@ -28,31 +46,53 @@ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })) -vi.mock('vuetify', () => ({ - useDisplay: () => ({ mdAndUp: { value: true } }), -})) - describe('GlassSettingsDialog', () => { beforeEach(() => { mocks.cancelGlassPreview.mockClear() mocks.commitGlassPreview.mockClear() mocks.previewGlassSettings.mockClear() + mocks.settings.value.glassAppearance = 'clear' + mocks.settings.value.glassDeformationStrength = 50 + mocks.settings.value.glassFlowStrength = 50 + mocks.settings.value.glassPreset = 'natural' + mocks.settings.value.glassQuality = 'css' + mocks.settings.value.glassReflectionStrength = 50 + mocks.settings.value.glassTranslationStrength = 50 + mocks.settings.value.glassTransparencyStrength = 50 }) it('cancels an active preview when the parent closes the dialog', async () => { const wrapper = shallowMount(GlassSettingsDialog, { global: { - stubs: { VDialogCloseBtn: true }, + stubs: { + VCard: slotStub, + VCardText: slotStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, }, props: { modelValue: true }, }) + const sliders = wrapper.findAll('.slider-stub') + expect(sliders).toHaveLength(2) + expect(sliders[0].attributes('data-disabled')).toBe('undefined') + expect(sliders[1].attributes('data-disabled')).toBe('undefined') await wrapper.setProps({ modelValue: false }) expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce() }) - it('resets the draft to the default glass settings without committing', async () => { + it('resets parameters to the current material, quality, and preset without committing', async () => { + mocks.settings.value.glassAppearance = 'frosted' + mocks.settings.value.glassPreset = 'liquid' + mocks.settings.value.glassQuality = 'high' + mocks.settings.value.glassDeformationStrength = 65 + mocks.settings.value.glassFlowStrength = 61 + mocks.settings.value.glassReflectionStrength = 58 + mocks.settings.value.glassTranslationStrength = 57 + mocks.settings.value.glassTransparencyStrength = 55 const wrapper = shallowMount(GlassSettingsDialog, { global: { stubs: { @@ -66,6 +106,7 @@ describe('GlassSettingsDialog', () => { VDialog: slotStub, VDialogCloseBtn: true, VDivider: true, + VSlider: sliderStub, }, }, props: { modelValue: true }, @@ -75,9 +116,143 @@ describe('GlassSettingsDialog', () => { await resetButton.trigger('click') expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ - glassAppearance: 'clear', - glassQuality: 'balanced', + glassDeformationStrength: 82, + glassFlowStrength: 80, + glassPreset: 'liquid', + glassReflectionStrength: 44, + glassTranslationStrength: 54, + glassTransparencyStrength: 46, }) expect(mocks.commitGlassPreview).not.toHaveBeenCalled() }) + + it('keeps static reflection adjustable in standard quality', async () => { + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardText: slotStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + const sliders = wrapper.findAll('.slider-stub') + + expect(sliders).toHaveLength(2) + expect(sliders[1].attributes('data-disabled')).toBe('undefined') + await sliders[1].setValue('86') + + expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 }) + }) + + it('hides preset choices in standard quality', () => { + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardText: slotStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + + expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false) + expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false) + }) + + it('keeps the selected preset highlighted after slider adjustments', async () => { + mocks.settings.value.glassQuality = 'balanced' + mocks.settings.value.glassPreset = 'glide' + mocks.settings.value.glassTransparencyStrength = 61 + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardText: slotStub, + VBtn: slotStub, + VBtnToggle: toggleStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + + const preset = wrapper.find('.glass-settings-dialog__preset') + const sliders = wrapper.findAll('.slider-stub') + + expect(preset.attributes('data-model-value')).toBe('glide') + expect(wrapper.findAll('.glass-settings-dialog__preset-option')).toHaveLength(3) + expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false) + + await sliders[0].setValue('77') + + expect(preset.attributes('data-model-value')).toBe('glide') + }) + + it('normalizes and previews all five independent slider values', async () => { + mocks.settings.value.glassQuality = 'balanced' + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardText: slotStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + const sliders = wrapper.findAll('.slider-stub') + + expect(sliders).toHaveLength(5) + expect(sliders.every(slider => slider.attributes('data-disabled') !== 'true')).toBe(true) + + await sliders[0].setValue('91.4') + await sliders[1].setValue('73.6') + await sliders[2].setValue('84.2') + await sliders[3].setValue('68.7') + await sliders[4].setValue('62.2') + + expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 }) + expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassReflectionStrength: 74 }) + expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassTranslationStrength: 84 }) + expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassDeformationStrength: 69 }) + expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassFlowStrength: 62 }) + expect(mocks.commitGlassPreview).not.toHaveBeenCalled() + }) + + it('keeps motion tuning available in high quality', () => { + mocks.settings.value.glassQuality = 'high' + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardText: slotStub, + VDialog: slotStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + const sliders = wrapper.findAll('.slider-stub') + + expect(sliders).toHaveLength(5) + expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([ + 'theme.glassTransparencyStrength', + 'theme.glassReflectionStrength', + 'theme.glassTranslationStrength', + 'theme.glassDeformationStrength', + 'theme.glassFlowStrength', + ]) + }) }) diff --git a/src/components/theme/GlassOpticalLayer.vue b/src/components/theme/GlassOpticalLayer.vue index 20932cc8..2d4962eb 100644 --- a/src/components/theme/GlassOpticalLayer.vue +++ b/src/components/theme/GlassOpticalLayer.vue @@ -1,32 +1,113 @@ diff --git a/src/components/theme/ThemeCustomizer.vue b/src/components/theme/ThemeCustomizer.vue index 513659ea..9fe2f4bc 100644 --- a/src/components/theme/ThemeCustomizer.vue +++ b/src/components/theme/ThemeCustomizer.vue @@ -12,6 +12,7 @@ import { import { usePWA } from '@/composables/usePWA' import { useI18n } from 'vue-i18n' import { useTheme } from 'vuetify' +import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics' const emit = defineEmits<{ 'close': [] @@ -140,7 +141,12 @@ 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.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT || + settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT || settings.value.radius !== 'default' || settings.value.shadow !== '0' || settings.value.skin !== 'default' || diff --git a/src/components/theme/__tests__/GlassOpticalLayer.spec.ts b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts new file mode 100644 index 00000000..cd469e21 --- /dev/null +++ b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts @@ -0,0 +1,52 @@ +import { shallowMount } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue' + +const rendererCalls = vi.hoisted(() => [] as Array>) +const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() })) + +vi.mock('@/composables/useGlassOpticalRenderer', () => ({ + setGlassRendererState: vi.fn((state: { value: string }, value: string) => { + state.value = value + }), + useGlassOpticalInteractionSource: vi.fn(() => interactionSource), + useGlassOpticalRenderer: vi.fn((options: Record) => { + rendererCalls.push(options) + + return { + renderedFrames: ref(0), + state: ref('ready'), + } + }), +})) + +describe('GlassOpticalLayer', () => { + it('uses exactly two visible presentation contexts with one interaction source', () => { + rendererCalls.length = 0 + const wrapper = shallowMount(GlassOpticalLayer, { + props: { + appearance: 'clear', + deformationStrength: 50, + flowStrength: 50, + previousWallpaperUrl: '', + quality: 'balanced', + reflectionStrength: 50, + routeKey: '/dashboard', + tintColor: '#8D51F9', + transitionDuration: 1500, + transitionStartedAt: 0, + translationStrength: 50, + transparencyStrength: 50, + wallpaperUrl: '/wallpaper.jpg', + }, + }) + + const canvases = wrapper.findAll('canvas') + expect(canvases).toHaveLength(2) + expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll']) + expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll']) + expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true) + expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true) + }) +}) diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 5ed64d2e..7462bdba 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -7,6 +7,7 @@ import { } from '@/composables/useGlassOpticalRenderer' import { effectScope, nextTick, ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle' vi.mock('three', async importOriginal => { const actual = await importOriginal() @@ -26,11 +27,15 @@ vi.mock('three', async importOriginal => { return Promise.resolve() } + clear() {} dispose() {} forceContextLoss() {} render() {} setClearColor() {} setPixelRatio() {} + setRenderTarget() {} + setScissor() {} + setScissorTest() {} setSize() {} }, } @@ -63,6 +68,59 @@ function appendOpticalSurface(className: string, bounds: Pick) { + element.getBoundingClientRect = () => + ({ + ...bounds, + bottom: bounds.y + bounds.height, + left: bounds.x, + right: bounds.x + bounds.width, + top: bounds.y, + toJSON: () => ({}), + }) as DOMRect +} + +function stubMediaPreferences({ coarsePointer = false, reducedMotion = false, reducedTransparency = false } = {}) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query: string) => ({ + addEventListener: vi.fn(), + matches: + (query === '(pointer: coarse)' && coarsePointer) || + (query === '(prefers-reduced-motion: reduce)' && reducedMotion) || + (query === '(prefers-reduced-transparency: reduce)' && reducedTransparency), + media: query, + removeEventListener: vi.fn(), + })), + ) +} + +function createTouchList(points: Array<{ clientX: number; clientY: number; identifier: number }>) { + const touches = points.map(point => point as Touch) + + return Object.assign(touches, { + item(index: number) { + return touches[index] ?? null + }, + }) as unknown as TouchList +} + +function dispatchTouchEvent( + type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart', + touches: Array<{ clientX: number; clientY: number; identifier: number }>, + changedTouches = touches, +) { + const event = new Event(type) as TouchEvent + Object.defineProperties(event, { + changedTouches: { value: createTouchList(changedTouches) }, + touches: { value: createTouchList(touches) }, + }) + window.dispatchEvent(event) + + return event +} + beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverMock) vi.stubGlobal('WebGLRenderingContext', class {}) @@ -70,6 +128,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() document.body.replaceChildren() @@ -79,14 +138,14 @@ afterEach(() => { describe('glass optical surface discovery', () => { it('detects a target surface added directly', () => { const surface = document.createElement('section') - surface.className = 'dashboard-grid-item-content' + surface.dataset.glassOpticalSurface = '' expect(containsGlassOpticalSurface(surface)).toBe(true) }) it('detects a target surface inside an asynchronously added subtree', () => { const wrapper = document.createElement('div') - wrapper.innerHTML = '
' + wrapper.innerHTML = '
' expect(containsGlassOpticalSurface(wrapper)).toBe(true) expect(containsGlassOpticalSurface(document.createElement('div'))).toBe(false) @@ -115,7 +174,8 @@ describe('glass optical surface discovery', () => { it('excludes the navbar from mobile clear optical surfaces', () => { appendOpticalSurface('layout-navbar', { height: 64, width: 390, x: 0, y: 0 }) - appendOpticalSurface('dashboard-grid-item-content', { height: 200, width: 350, x: 20, y: 120 }) + const content = appendOpticalSurface('dashboard-content', { height: 200, width: 350, x: 20, y: 120 }) + content.dataset.glassOpticalSurface = '' const rects = collectGlassOpticalRects(390, 800, 'clear') @@ -139,6 +199,61 @@ describe('glass optical surface discovery', () => { ]) }) + it('uses the actual dashboard card boundary and all four corner radii', () => { + const shell = document.createElement('section') + shell.className = 'dashboard-grid-item-content' + shell.innerHTML = ` +
+
+
+
+
+ ` + const card = shell.querySelector('.v-card')! + card.style.borderTopLeftRadius = '24px' + card.style.borderTopRightRadius = '20px' + card.style.borderBottomRightRadius = '16px' + card.style.borderBottomLeftRadius = '12px' + setOpticalSurfaceBounds(shell, { height: 220, width: 360, x: 20, y: 100 }) + setOpticalSurfaceBounds(card, { height: 200, width: 340, x: 30, y: 110 }) + document.body.append(shell) + + expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([ + expect.objectContaining({ + height: 200, + radii: [24, 20, 16, 12], + width: 340, + x: 30, + y: 110, + }), + ]) + }) + + it('discovers explicit optical surfaces without component-specific selectors', () => { + const surface = appendOpticalSurface('custom-surface', { height: 180, width: 300, x: 40, y: 120 }) + surface.dataset.glassOpticalSurface = '' + surface.style.borderTopLeftRadius = '18px' + surface.style.borderTopRightRadius = '18px' + surface.style.borderBottomRightRadius = '18px' + surface.style.borderBottomLeftRadius = '18px' + + expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([ + expect.objectContaining({ height: 180, radii: [18, 18, 18, 18], width: 300, x: 40, y: 120 }), + ]) + }) + + it('discovers the shared interactive card contract used across routes', () => { + const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 }) + surface.style.borderTopLeftRadius = '20px' + surface.style.borderTopRightRadius = '20px' + surface.style.borderBottomRightRadius = '20px' + surface.style.borderBottomLeftRadius = '20px' + + expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([ + expect.objectContaining({ height: 220, radii: [20, 20, 20, 20], width: 150, x: 24, y: 96 }), + ]) + }) + it('recovers after consecutive WebGL context loss cycles', async () => { const canvas = document.createElement('canvas') const scope = effectScope() @@ -177,6 +292,7 @@ describe('glass optical surface discovery', () => { const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => ++frameId) const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame') const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss') const resizeDisconnect = vi.spyOn(ResizeObserverMock.prototype, 'disconnect') const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect') @@ -200,16 +316,24 @@ describe('glass optical surface discovery', () => { await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) expect(requestFrame).toHaveBeenCalled() expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1) expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1) expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(1) expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1) + const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length active.value = false await nextTick() expect(rendererDispose).toHaveBeenCalledTimes(1) + expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2) expect(contextLoss).toHaveBeenCalledTimes(1) expect(resizeDisconnect).toHaveBeenCalledTimes(1) expect(mutationDisconnect).toHaveBeenCalledTimes(1) @@ -224,16 +348,24 @@ describe('glass optical surface discovery', () => { expect(renderer?.state.value).toBe('ready') expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1) expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1) expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1) - expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(0) + expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(0) + expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(0) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1) + const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length active.value = false await nextTick() expect(rendererDispose).toHaveBeenCalledTimes(2) + expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2) expect(contextLoss).toHaveBeenCalledTimes(2) expect(resizeDisconnect).toHaveBeenCalledTimes(2) expect(mutationDisconnect).toHaveBeenCalledTimes(2) @@ -241,4 +373,680 @@ describe('glass optical surface discovery', () => { scope.stop() }) + + it('reuses the renderer context while switching quality profiles', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + const quality = ref<'balanced' | 'high'>('high') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + quality, + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss') + const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + + quality.value = 'balanced' + await nextTick() + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + + expect(rendererDispose).not.toHaveBeenCalled() + expect(contextLoss).not.toHaveBeenCalled() + expect(renderTargetDispose).toHaveBeenCalledTimes(2) + + quality.value = 'high' + await nextTick() + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + + expect(rendererDispose).not.toHaveBeenCalled() + expect(contextLoss).not.toHaveBeenCalled() + expect(renderTargetDispose).toHaveBeenCalledTimes(4) + + scope.stop() + }) + + it('keeps old and new wallpaper textures in the same renderer during the shared transition', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg') + const previousWallpaperUrl = ref('') + const transitionStartedAt = ref(0) + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('frosted'), + canvas: ref(canvas), + quality: ref('high'), + previousWallpaperUrl, + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + transitionDuration: ref(1500), + transitionStartedAt, + wallpaperUrl, + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + + const replacementTexture = new three.Texture() + replacementTexture.image = { + height: 1, + naturalHeight: 1, + naturalWidth: 1, + width: 1, + } as HTMLImageElement + let resolveTexture!: (texture: typeof replacementTexture) => void + vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockReturnValueOnce( + new Promise(resolve => { + resolveTexture = resolve + }), + ) + + previousWallpaperUrl.value = wallpaperUrl.value + transitionStartedAt.value = performance.now() + wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg' + await nextTick() + + expect(renderer?.state.value).toBe('ready') + await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBe('true')) + + resolveTexture(replacementTexture) + await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined()) + await vi.waitFor(() => expect(render).toHaveBeenCalled()) + expect(renderer?.state.value).toBe('ready') + const scene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uPreviousTexture: { value: unknown } + uTexture: { value: unknown } + uTextureMix: { value: number } + } + } + }> + } + const uniforms = scene.children[0].material.uniforms + expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value) + expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0) + expect(uniforms.uTextureMix.value).toBeLessThan(1) + scope.stop() + }) + + it('keeps the active texture and ready state when a replacement wallpaper fails', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl, + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockRejectedValueOnce(new Error('replacement failed')) + + wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg' + await nextTick() + await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined()) + + expect(renderer?.state.value).toBe('ready') + expect(rendererDispose).not.toHaveBeenCalled() + scope.stop() + }) + + it('pauses briefly hidden renderers and resumes a stable frame without disposing resources', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + let visibilityState: DocumentVisibilityState = 'visible' + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + vi.useFakeTimers() + + visibilityState = 'hidden' + document.dispatchEvent(new Event('visibilitychange')) + await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1) + expect(dispose).not.toHaveBeenCalled() + + visibilityState = 'visible' + window.dispatchEvent(new Event('focus')) + await nextTick() + await Promise.resolve() + expect(dispose).not.toHaveBeenCalled() + expect(render).toHaveBeenCalled() + scope.stop() + }) + + it('releases renderer resources after the long background timeout', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + let visibilityState: DocumentVisibilityState = 'visible' + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + quality: ref('high'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + vi.useFakeTimers() + visibilityState = 'hidden' + document.dispatchEvent(new Event('visibilitychange')) + + await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS) + expect(dispose).toHaveBeenCalledTimes(1) + scope.stop() + }) + + it('uses the CSS fallback when reduced transparency is active', async () => { + stubMediaPreferences({ reducedTransparency: true }) + const canvas = document.createElement('canvas') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + quality: ref('high'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback')) + expect(document.documentElement.dataset.glassRendererState).toBe('fallback') + scope.stop() + }) + + it('tracks the actual passive touch coordinate without restoring press magnification', async () => { + stubMediaPreferences({ coarsePointer: true }) + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + const surface = appendOpticalSurface('app-hover-lift-card', { height: 500, width: 350, x: 20, y: 100 }) + surface.style.borderRadius = '20px' + const canvas = document.createElement('canvas') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('frosted'), + canvas: ref(canvas), + quality: ref('balanced'), + routeKey: ref('/recommend'), + 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 < 3 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now())) + } + render.mockClear() + + dispatchTouchEvent('touchstart', [{ clientX: 80, clientY: 180, identifier: 7 }]) + const startFrames = [...callbacks.values()] + callbacks.clear() + startFrames.forEach(callback => callback(performance.now())) + render.mockClear() + + const moveEvent = dispatchTouchEvent('touchmove', [{ clientX: 180, clientY: 320, identifier: 7 }]) + const interactionFrames = [...callbacks.values()] + callbacks.clear() + interactionFrames.forEach(callback => callback(moveEvent.timeStamp + 16)) + + const scene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uPointer: { value: { x: number; y: number } } + uTrail: { value: Array<{ x: number; y: number; z: number }> } + } + } + }> + } + const pointer = scene.children[0].material.uniforms.uPointer.value + const trail = scene.children[0].material.uniforms.uTrail.value + expect(pointer.x).toBeCloseTo(180 / 390) + expect(pointer.y).toBeCloseTo(1 - 320 / 844) + expect(trail[0]).toMatchObject({ z: 1 }) + expect(trail[0].x).toBeCloseTo(180 / 390) + expect(trail[0].y).toBeCloseTo(1 - 320 / 844) + expect(trail[1]).toMatchObject({ z: 0.72 }) + expect(trail[1].x).toBeCloseTo(80 / 390) + expect(trail[1].y).toBeCloseTo(1 - 180 / 844) + + dispatchTouchEvent('touchend', [], [{ clientX: 180, clientY: 320, identifier: 7 }]) + scope.stop() + }) + + it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => { + stubMediaPreferences({ coarsePointer: true }) + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + const surface = appendOpticalSurface('test-surface', { height: 500, width: 350, x: 20, y: 100 }) + surface.dataset.glassOpticalSurface = '' + const canvas = document.createElement('canvas') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('frosted'), + canvas: ref(canvas), + quality: ref('high'), + 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 < 3 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now())) + } + expect(callbacks.size).toBe(0) + + vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120) + setOpticalSurfaceBounds(surface, { height: 500, width: 350, x: 20, y: -20 }) + 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() + 160 + pass * 16)) + } + + const settledScene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uMotion: { value: number } + uRects: { value: Array<{ y: number }> } + uScrollOffset: { value: { y: number } } + uTrail: { value: Array<{ z: number }> } + } + } + }> + } + const settledUniforms = settledScene.children[0].material.uniforms + expect(settledUniforms.uMotion.value).toBe(0) + expect(settledUniforms.uTrail.value.every(trail => trail.z === 0)).toBe(true) + expect(settledUniforms.uRects.value[0].y).toBeCloseTo(1 - (100 + 500) / 844) + expect(settledUniforms.uScrollOffset.value.y).toBe(120) + expect(callbacks.size).toBe(0) + 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) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + const surfaces = [ + { x: 100, name: 'A' }, + { x: 400, name: 'B' }, + ] + surfaces.forEach(({ name, x }) => { + const surface = appendOpticalSurface('app-hover-lift-card', { + height: 180, + width: 180, + x, + y: 100, + }) + surface.dataset.testSurface = name + }) + const canvas = document.createElement('canvas') + const deformationStrength = ref(50) + const flowStrength = ref(50) + const reflectionStrength = ref(50) + const translationStrength = ref(50) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + deformationStrength, + flowStrength, + quality: ref('balanced'), + reflectionStrength, + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + translationStrength, + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now())) + } + render.mockClear() + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })) + const [cardAFrame] = callbacks.values() + callbacks.clear() + cardAFrame(performance.now() + 16) + const scene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uDeformationStrength: { value: number } + uFlowStrength: { value: number } + uMaxRefractionPixels: { value: number } + uMotionExpansion: { value: number } + uRects: { value: Array<{ x: number }> } + uReflectionStrength: { value: number } + uTransparency: { value: number } + uTranslationStrength: { value: number } + uSurfaceWeights: { value: number[] } + } + fragmentShader: string + } + }> + } + const uniforms = scene.children[0].material.uniforms + const cardAX = 100 / 1200 + const cardBX = 400 / 1200 + expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true) + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 330, clientY: 160 })) + expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true) + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 460, clientY: 160 })) + const cardAIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardAX) < 0.001) + const cardBIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardBX) < 0.001) + expect(cardAIndex).toBeGreaterThanOrEqual(0) + expect(cardBIndex).toBeGreaterThanOrEqual(0) + expect(uniforms.uSurfaceWeights.value[cardAIndex]).toBe(1) + expect(uniforms.uSurfaceWeights.value[cardBIndex]).toBe(1) + expect(uniforms.uTranslationStrength.value).toBe(1) + expect(uniforms.uDeformationStrength.value).toBe(1) + expect(uniforms.uFlowStrength.value).toBe(1) + expect(uniforms.uMotionExpansion.value).toBe(0) + expect(uniforms.uMaxRefractionPixels.value).toBe(6) + expect(uniforms.uReflectionStrength.value).toBe(1) + expect(uniforms.uTransparency.value).toBeGreaterThan(0.6) + + translationStrength.value = 100 + deformationStrength.value = 100 + flowStrength.value = 100 + reflectionStrength.value = 80 + await nextTick() + expect(uniforms.uTranslationStrength.value).toBeCloseTo(1.7) + expect(uniforms.uDeformationStrength.value).toBeCloseTo(1.55) + expect(uniforms.uFlowStrength.value).toBeCloseTo(1.45) + expect(uniforms.uMotionExpansion.value).toBe(1) + expect(uniforms.uMaxRefractionPixels.value).toBe(6) + expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1) + + expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +') + expect(scene.children[0].material.fragmentShader).toContain( + 'materialEnergy = max(materialEnergy, liquidEnergy * rectMask)', + ) + expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction') + expect(scene.children[0].material.fragmentShader).toContain('getContentProtection') + expect(scene.children[0].material.fragmentShader).toContain('sampleHighQualityDiffuse') + expect(scene.children[0].material.fragmentShader).toContain('singleSpecular') + expect(scene.children[0].material.fragmentShader).not.toContain('stableSample') + expect(scene.children[0].material.fragmentShader).not.toContain('broadReflection') + expect(scene.children[0].material.fragmentShader).toContain('flowSurfaceDetail * 0.38') + expect(scene.children[0].material.fragmentShader).not.toContain('pointerCurvature') + expect(scene.children[0].material.fragmentShader).not.toContain('wakeCurvature') + expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature') + 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 uFlowStrength') + expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength') + expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency') + expect(scene.children[0].material.fragmentShader).toContain('uMotion *') + expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength') + expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))') + expect(scene.children[0].material.fragmentShader).toContain('float highlightBudget') + expect(scene.children[0].material.fragmentShader).toContain('float absorption') + expect(scene.children[0].material.fragmentShader).toContain( + 'causticHighlightMix * uReflectionStrength * highlightBudget', + ) + expect(scene.children[0].material.fragmentShader).toContain('mix(0.78, 0.94, uTransparency)') + expect(scene.children[0].material.fragmentShader).not.toContain('mix(0.035, 0.4') + expect(scene.children[0].material.fragmentShader).not.toContain('sin(') + + scope.stop() + }) + + it('settles pointer feedback without leaving a continuous animation frame', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + const surface = appendOpticalSurface('test-surface', { height: 240, width: 320, x: 20, y: 80 }) + surface.dataset.glassOpticalSurface = '' + const canvas = document.createElement('canvas') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(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 < 3 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now())) + } + expect(callbacks.size).toBe(0) + render.mockClear() + + const pointerMove = new MouseEvent('pointermove', { clientX: 160, clientY: 160 }) + window.dispatchEvent(pointerMove) + expect(callbacks.size).toBe(1) + + const [firstInteractionFrame] = callbacks.values() + callbacks.clear() + firstInteractionFrame(pointerMove.timeStamp + 100) + const firstScene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } } + }> + } + const firstVelocity = firstScene.children[0].material.uniforms.uPointerVelocity.value + const stableDirection = { x: firstVelocity.x, y: firstVelocity.y } + + const [secondInteractionFrame] = callbacks.values() + callbacks.clear() + secondInteractionFrame(pointerMove.timeStamp + 300) + const secondScene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } } + }> + } + expect(secondScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject(stableDirection) + + const [finalInteractionFrame] = callbacks.values() + callbacks.clear() + finalInteractionFrame(pointerMove.timeStamp + 500) + const finalScene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } } + }> + } + + expect(callbacks.size).toBe(0) + expect(finalScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 }) + scope.stop() + }) + + it('keeps immediate translation and deformation at zero flow without scheduling inertia', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => { + callbacks.delete(id) + }) + appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + deformationStrength: ref(70), + flowStrength: ref(0), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + translationStrength: ref(70), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now())) + } + render.mockClear() + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })) + expect(callbacks.size).toBe(1) + const [frame] = callbacks.values() + callbacks.clear() + frame(performance.now() + 16) + + const scene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uDeformationStrength: { value: number } + uFlowStrength: { value: number } + uMotion: { value: number } + uTranslationStrength: { value: number } + } + } + }> + } + const uniforms = scene.children[0].material.uniforms + expect(uniforms.uMotion.value).toBe(1) + expect(uniforms.uTranslationStrength.value).toBeGreaterThan(1) + expect(uniforms.uDeformationStrength.value).toBeGreaterThan(1) + expect(uniforms.uFlowStrength.value).toBe(0) + expect(callbacks.size).toBe(0) + scope.stop() + }) }) diff --git a/src/composables/__tests__/useThemeCustomizer.spec.ts b/src/composables/__tests__/useThemeCustomizer.spec.ts index 84c5db49..218bc990 100644 --- a/src/composables/__tests__/useThemeCustomizer.spec.ts +++ b/src/composables/__tests__/useThemeCustomizer.spec.ts @@ -6,6 +6,7 @@ import { previewGlassSettings, readThemeCustomizerSettings, THEME_CUSTOMIZER_STORAGE_KEY, + useEffectiveGlassSettings, } from '@/composables/useThemeCustomizer' import { beforeEach, describe, expect, it } from 'vitest' @@ -17,11 +18,17 @@ describe('useThemeCustomizer glass settings', () => { localStorage.clear() }) - it('uses clear appearance and balanced quality by default', () => { + it('uses the checkpoint appearance, quality, and strength values by default', () => { const settings = readThemeCustomizerSettings() expect(settings.glassAppearance).toBe('clear') + expect(settings.glassDeformationStrength).toBe(50) + expect(settings.glassFlowStrength).toBe(50) + expect(settings.glassPreset).toBe('natural') expect(settings.glassQuality).toBe('balanced') + expect(settings.glassReflectionStrength).toBe(50) + expect(settings.glassTranslationStrength).toBe(50) + expect(settings.glassTransparencyStrength).toBe(50) }) it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => { @@ -39,15 +46,47 @@ describe('useThemeCustomizer glass settings', () => { it('falls back when stored glass settings are invalid', () => { localStorage.setItem( THEME_CUSTOMIZER_STORAGE_KEY, - JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }), + JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }), ) const settings = readThemeCustomizerSettings() expect(settings.glassAppearance).toBe('clear') + expect(settings.glassPreset).toBe('natural') expect(settings.glassQuality).toBe('balanced') }) + it('rounds and clamps persisted optical strength values', () => { + localStorage.setItem( + THEME_CUSTOMIZER_STORAGE_KEY, + JSON.stringify({ + glassDeformationStrength: -12, + glassFlowStrength: 42.6, + glassReflectionStrength: 140.6, + glassTranslationStrength: 101, + glassTransparencyStrength: 83.7, + }), + ) + + expect(readThemeCustomizerSettings()).toMatchObject({ + glassDeformationStrength: 0, + glassFlowStrength: 43, + glassReflectionStrength: 100, + glassTranslationStrength: 100, + glassTransparencyStrength: 84, + }) + }) + + it('migrates the legacy motion value into translation, deformation, and flow', () => { + localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassMotionStrength: 67 })) + + expect(readThemeCustomizerSettings()).toMatchObject({ + glassDeformationStrength: 67, + glassFlowStrength: 67, + glassTranslationStrength: 67, + }) + }) + it('syncs glass settings to the document roots', () => { const settings = readThemeCustomizerSettings() @@ -61,6 +100,10 @@ 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.5') + expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.5') + expect(document.documentElement.style.getPropertyValue('--glass-transparency')).toBe('0.5') + expect(document.body.style.getPropertyValue('--glass-transparency')).toBe('0.5') }) it('previews glass settings without persisting them', () => { @@ -76,11 +119,29 @@ describe('useThemeCustomizer glass settings', () => { it('commits the latest glass preview as one persisted state', () => { previewGlassSettings({ glassAppearance: 'tinted' }) previewGlassSettings({ glassAppearance: 'clear' }) - previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' }) + previewGlassSettings({ + glassAppearance: 'tinted', + glassDeformationStrength: 74, + glassFlowStrength: 63, + glassPreset: 'glide', + glassQuality: 'css', + glassReflectionStrength: 81, + glassTranslationStrength: 69, + glassTransparencyStrength: 80, + }) commitGlassPreview() - expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' }) + expect(readThemeCustomizerSettings()).toMatchObject({ + glassAppearance: 'tinted', + glassDeformationStrength: 74, + glassFlowStrength: 63, + glassPreset: 'glide', + glassQuality: 'css', + glassReflectionStrength: 81, + glassTranslationStrength: 69, + glassTransparencyStrength: 80, + }) expect(document.documentElement.dataset.glassAppearance).toBe('tinted') }) @@ -96,12 +157,42 @@ describe('useThemeCustomizer glass settings', () => { }) it('restores persisted glass settings when a preview is cancelled', () => { - persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' }) - previewGlassSettings({ glassAppearance: 'clear' }) + const effective = useEffectiveGlassSettings() + persistPartialThemeCustomizerSettings({ + glassAppearance: 'tinted', + glassDeformationStrength: 42, + glassFlowStrength: 44, + glassReflectionStrength: 66, + glassTranslationStrength: 46, + glassTransparencyStrength: 72, + }) + previewGlassSettings({ + glassAppearance: 'clear', + glassDeformationStrength: 90, + glassFlowStrength: 88, + glassReflectionStrength: 12, + glassTranslationStrength: 86, + glassTransparencyStrength: 94, + }) + expect(effective.value).toMatchObject({ + glassAppearance: 'clear', + glassDeformationStrength: 90, + glassFlowStrength: 88, + glassReflectionStrength: 12, + glassTranslationStrength: 86, + glassTransparencyStrength: 94, + }) cancelGlassPreview() expect(document.documentElement.dataset.glassAppearance).toBe('tinted') - expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted') + expect(effective.value).toMatchObject({ + glassAppearance: 'tinted', + glassDeformationStrength: 42, + glassFlowStrength: 44, + glassReflectionStrength: 66, + glassTranslationStrength: 46, + glassTransparencyStrength: 72, + }) }) }) diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index 805b8608..e0757013 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -10,22 +10,51 @@ import type { Texture, Vector2, Vector4, + WebGLRenderTarget, WebGLRenderer, } from 'three' import { canUseGlassWallpaperTexture, + GLASS_OPTICAL_MAX_SURFACES_DESKTOP, + GLASS_OPTICAL_MAX_SURFACES_MOBILE, + GLASS_OPTICAL_STRENGTH_DEFAULT, getGlassCoverScale, getGlassOpticalBufferSize, + getGlassOpticalDecay, + getGlassOpticalDeformationStrengthScale, + getGlassOpticalFlowStrengthScale, + getGlassOpticalMaxRefractionPixels, + getGlassOpticalMotionExpansion, + getGlassOpticalMotionEnergy, + getGlassOpticalReflectionStrengthScale, getGlassOpticalRenderProfile, + getGlassScrollBufferSize, + getGlassOpticalTransparency, + getGlassOpticalTranslationStrengthScale, + getGlassOpticalSurfaceTransitionWeights, + getGlassOpticalWakeDirection, + getGlassWallpaperTransitionProgress, normalizeGlassOpticalRect, + reconcileGlassOpticalSurfaceSlots, selectGlassOpticalRects, + stepGlassOpticalSpring, + type GlassCornerRadii, type GlassOpticalQuality, type GlassOpticalRect, + type GlassOpticalSurfaceCandidate, + type GlassOpticalSurfaceSlot, } from '@/utils/glassOptics' import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer' +import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle' type ThreeModule = typeof import('three') export type GlassRendererState = 'fallback' | 'loading' | 'ready' +export type GlassPresentationSpace = 'fixed' | 'scroll' + +export interface GlassOpticalInteractionSource { + /** 订阅同一组原始指针输入;各呈现层仅负责转换自己的坐标空间。 */ + subscribe(listener: (event: PointerEvent | TouchEvent) => void): () => void +} /** 同步组件状态和根节点属性,确保 CSS 回退与 renderer 生命周期一致。 */ export function setGlassRendererState(state: Ref, value: GlassRendererState) { @@ -33,19 +62,80 @@ export function setGlassRendererState(state: Ref, value: Gla document.documentElement.dataset.glassRendererState = value } +/** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */ +export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSource { + const listeners = new Set<(event: PointerEvent | TouchEvent) => void>() + const dispatch = (event: PointerEvent | TouchEvent) => { + for (const listener of listeners) listener(event) + } + + window.addEventListener('pointermove', dispatch, { passive: true }) + window.addEventListener('touchstart', dispatch, { passive: true }) + window.addEventListener('touchmove', dispatch, { passive: true }) + window.addEventListener('touchend', dispatch, { passive: true }) + window.addEventListener('touchcancel', dispatch, { passive: true }) + + onScopeDispose(() => { + listeners.clear() + window.removeEventListener('pointermove', dispatch) + window.removeEventListener('touchstart', dispatch) + window.removeEventListener('touchmove', dispatch) + window.removeEventListener('touchend', dispatch) + window.removeEventListener('touchcancel', dispatch) + }) + + return { + subscribe(listener) { + listeners.add(listener) + + return () => listeners.delete(listener) + }, + } +} + interface GlassRendererUniforms extends Record { uAppearance: IUniform uCoverScale: IUniform + uDeformationStrength: IUniform + uFlowTexture: IUniform + uFlowStrength: IUniform uHasWallpaperTexture: IUniform + uHasFlowTexture: IUniform + uMotion: IUniform + uMotionExpansion: IUniform + uMaxRefractionPixels: IUniform uPointer: IUniform + uPointerVelocity: IUniform + uPresentationSize: IUniform + uPreviousCoverScale: IUniform uQuality: IUniform - uRadii: IUniform + uReflectionStrength: IUniform + uRadii: IUniform uRectCount: IUniform uRects: IUniform + uSurfaceWeights: IUniform + uPreviousTexture: IUniform uTexture: IUniform + uTextureMix: IUniform uTintColor: IUniform - uTime: IUniform - uViewportSize: IUniform + uTransparency: IUniform + uTranslationStrength: IUniform + uTrail: IUniform + uTrailCount: IUniform + uVisibleViewportSize: IUniform + uScrollOffset: IUniform + uWakeDirection: IUniform + uWakeProgress: IUniform +} + +interface GlassFlowUniforms extends Record { + uDecay: IUniform + uInjection: IUniform + uPointer: IUniform + uPrevious: IUniform + uTexelSize: IUniform + uVelocity: IUniform + uViewportAspect: IUniform } interface GlassRendererResources { @@ -58,24 +148,61 @@ interface GlassRendererResources { uniforms: GlassRendererUniforms } +interface GlassFlowResources { + material: ShaderMaterial + mesh: Mesh + readTarget: WebGLRenderTarget + scene: Scene + uniforms: GlassFlowUniforms + writeTarget: WebGLRenderTarget +} + interface UseGlassOpticalRendererOptions { active: MaybeRefOrGetter appearance: MaybeRefOrGetter canvas: Ref + deformationStrength?: MaybeRefOrGetter + flowStrength?: MaybeRefOrGetter + interactionSource?: GlassOpticalInteractionSource + /** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */ + motionStrength?: MaybeRefOrGetter quality: MaybeRefOrGetter + reflectionStrength?: MaybeRefOrGetter + previousWallpaperUrl?: MaybeRefOrGetter + transparencyStrength?: MaybeRefOrGetter routeKey: MaybeRefOrGetter + syncDocumentState?: boolean + surfaceSpace?: GlassPresentationSpace tintColor: MaybeRefOrGetter + transitionDuration?: MaybeRefOrGetter + transitionStartedAt?: MaybeRefOrGetter + translationStrength?: MaybeRefOrGetter wallpaperUrl: MaybeRefOrGetter } +type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate + const SURFACE_SELECTORS = [ - { rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-card' }, - { rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-sheet' }, - { rank: 1, selector: '.agent-assistant-panel' }, - { rank: 1, selector: '.login-card' }, - { rank: 2, selector: '.layout-vertical-nav' }, - { rank: 2, selector: '.layout-navbar' }, - { rank: 3, selector: '.dashboard-grid-item-content' }, + { rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-card', space: 'fixed' }, + { rank: 1, selector: '.v-overlay--active .v-overlay__content > .v-sheet', space: 'fixed' }, + { rank: 1, selector: '.agent-assistant-panel', space: 'fixed' }, + { rank: 1, selector: '.login-card', space: 'fixed' }, + { rank: 2, selector: '.layout-vertical-nav', space: 'fixed' }, + { rank: 2, selector: '.layout-navbar', space: 'fixed' }, + { + rank: 3, + selector: '.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card', + space: 'scroll', + }, + { + rank: 3, + selector: + '.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card', + space: 'scroll', + }, + { rank: 3, selector: '[data-glass-optical-surface]', space: 'scroll' }, + // 推荐、订阅、媒体详情与设置页共用该交互卡片契约,不按业务路由维护 renderer 白名单。 + { rank: 4, selector: '.app-hover-lift-card', space: 'scroll' }, ] as const const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',') @@ -96,125 +223,487 @@ void main() { } ` +const FLOW_FRAGMENT_SHADER = ` +precision highp float; + +uniform sampler2D uPrevious; +uniform vec2 uPointer; +uniform vec2 uVelocity; +uniform vec2 uTexelSize; +uniform float uInjection; +uniform float uDecay; +uniform float uViewportAspect; +varying vec2 vUv; + +void main() { + vec4 previous = ( + texture2D(uPrevious, vUv) * 0.5 + + texture2D(uPrevious, vUv + vec2(uTexelSize.x, 0.0)) * 0.125 + + texture2D(uPrevious, vUv - vec2(uTexelSize.x, 0.0)) * 0.125 + + texture2D(uPrevious, vUv + vec2(0.0, uTexelSize.y)) * 0.125 + + texture2D(uPrevious, vUv - vec2(0.0, uTexelSize.y)) * 0.125 + ); + float previousEnergy = previous.z; + vec2 flow = previousEnergy < 0.001 ? vec2(0.0) : (previous.xy * 2.0 - 1.0) * uDecay; + float energy = previousEnergy * uDecay; + vec2 delta = vUv - uPointer; + delta.x *= uViewportAspect; + float distanceSquared = dot(delta, delta); + float injection = exp(-distanceSquared * 70.0) * uInjection; + float speed = length(uVelocity); + vec2 direction = speed > 0.0001 ? uVelocity / speed : vec2(0.0, -1.0); + vec2 perpendicular = vec2(-direction.y, direction.x); + float shear = dot(delta, perpendicular) * exp(-distanceSquared * 42.0); + + flow += (direction * min(speed * 9.0, 0.9) - perpendicular * shear * 0.85) * injection * 0.44; + energy = max(energy, injection); + + gl_FragColor = vec4(flow * 0.5 + 0.5, energy, 1.0); +} +` + const FRAGMENT_SHADER = ` precision highp float; +uniform sampler2D uPreviousTexture; uniform sampler2D uTexture; +uniform sampler2D uFlowTexture; uniform vec2 uCoverScale; +uniform vec2 uPreviousCoverScale; +uniform vec2 uPresentationSize; +uniform vec2 uScrollOffset; +uniform vec2 uVisibleViewportSize; +uniform float uDeformationStrength; +uniform float uFlowStrength; uniform float uHasWallpaperTexture; +uniform float uHasFlowTexture; +uniform float uMotion; +uniform float uMotionExpansion; +uniform float uMaxRefractionPixels; uniform vec2 uPointer; +uniform vec2 uPointerVelocity; +uniform vec2 uWakeDirection; +uniform float uWakeProgress; uniform float uQuality; +uniform float uReflectionStrength; uniform vec4 uRects[8]; -uniform float uRadii[8]; +uniform vec4 uRadii[8]; +uniform float uSurfaceWeights[8]; uniform int uRectCount; uniform float uAppearance; uniform vec3 uTintColor; -uniform float uTime; -uniform vec2 uViewportSize; +uniform float uTransparency; +uniform float uTranslationStrength; +uniform float uTextureMix; +uniform vec4 uTrail[4]; +uniform int uTrailCount; varying vec2 vUv; -float roundedRectMask(vec2 local, vec2 rectSize, float radius) { +float cornerRadius(vec2 centered, vec4 radii) { + if (centered.x >= 0.0) return centered.y >= 0.0 ? radii.y : radii.z; + + return centered.y >= 0.0 ? radii.x : radii.w; +} + +float roundedRectMask(vec2 local, vec2 rectSize, vec4 radii) { + vec2 localPixels = (local - 0.5) * rectSize; + float radius = cornerRadius(localPixels, radii); float safeRadius = min(max(radius, 0.0), min(rectSize.x, rectSize.y) * 0.5); - vec2 centered = abs(local - 0.5) * rectSize - (rectSize * 0.5 - vec2(safeRadius)); + vec2 centered = abs(localPixels) - (rectSize * 0.5 - vec2(safeRadius)); float distanceToEdge = length(max(centered, 0.0)) + min(max(centered.x, centered.y), 0.0) - safeRadius; - return 1.0 - smoothstep(-1.0, 1.0, distanceToEdge); + return 1.0 - smoothstep(-1.5, 0.0, distanceToEdge); } vec2 coverUv(vec2 uv) { - return vec2(0.5) + (uv - vec2(0.5)) * uCoverScale; + vec2 documentPixels = vec2(uv.x, 1.0 - uv.y) * uPresentationSize; + vec2 viewportPixels = documentPixels - uScrollOffset; + vec2 viewportUv = vec2( + viewportPixels.x / max(uVisibleViewportSize.x, 1.0), + 1.0 - viewportPixels.y / max(uVisibleViewportSize.y, 1.0) + ); + + return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale; +} + +vec3 toneMapWallpaper(vec3 color, vec2 uv) { + float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance)); + float frosted = step(1.5, uAppearance); + float exposure = mix(0.86, 0.85, tinted); + exposure = mix(exposure, 0.82, frosted); + float saturation = mix(0.95, 0.97, tinted); + saturation = mix(saturation, 0.9, frosted); + float contrast = mix(1.02, 1.0, frosted); + float luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + vec3 mapped = mix(vec3(luminance), color, saturation); + mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure; + + float top = 1.0 - uv.y; + float linearStart = mix(0.14, 0.3, frosted); + float linearEnd = mix(mix(0.4, 0.42, tinted), 0.58, frosted); + float linearAbsorption = mix(linearStart, linearEnd, top); + vec2 radialDelta = (uv - vec2(0.5, 0.82)) / vec2(0.78, 1.0); + float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.16, 0.18, tinted); + radialAbsorption *= 1.0 - frosted; + + return mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption); +} + +vec3 sampleWallpaper(vec2 uv) { + vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001)); + vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale; + vec3 previous = texture2D(uPreviousTexture, previousUv).rgb; + vec3 current = texture2D(uTexture, uv).rgb; + + return toneMapWallpaper(mix(previous, current, uTextureMix), viewportUv); +} + +vec3 sampleChromatic(vec2 uv, float separation) { + return vec3( + sampleWallpaper(uv + vec2(separation, 0.0)).r, + sampleWallpaper(uv).g, + sampleWallpaper(uv - vec2(separation, 0.0)).b + ); +} + +vec3 sampleBalancedDiffuse(vec2 uv, vec2 axis, float radius) { + vec2 firstOffset = axis * radius; + vec2 secondOffset = vec2(-axis.y, axis.x) * radius; + + return ( + sampleWallpaper(uv) * 0.28 + + sampleWallpaper(uv + firstOffset) * 0.18 + + sampleWallpaper(uv - firstOffset) * 0.18 + + sampleWallpaper(uv + secondOffset) * 0.18 + + sampleWallpaper(uv - secondOffset) * 0.18 + ); +} + +vec3 sampleHighQualityDiffuse(vec2 uv, vec2 axis, float radius) { + vec2 firstOffset = axis * radius; + vec2 secondOffset = vec2(-axis.y, axis.x) * radius; + vec2 firstDiagonal = normalize(firstOffset + secondOffset) * radius * 1.15; + vec2 secondDiagonal = normalize(firstOffset - secondOffset) * radius * 1.15; + + return ( + sampleWallpaper(uv) * 0.2 + + sampleWallpaper(uv + firstOffset) * 0.12 + + sampleWallpaper(uv - firstOffset) * 0.12 + + sampleWallpaper(uv + secondOffset) * 0.12 + + sampleWallpaper(uv - secondOffset) * 0.12 + + sampleWallpaper(uv + firstDiagonal) * 0.08 + + sampleWallpaper(uv - firstDiagonal) * 0.08 + + sampleWallpaper(uv + secondDiagonal) * 0.08 + + sampleWallpaper(uv - secondDiagonal) * 0.08 + ); +} + +float getContentProtection(vec2 sourceUv) { + if (uQuality < 0.5 || uHasWallpaperTexture < 0.5) return 1.0; + + vec2 sourceTexel = max(uCoverScale, vec2(0.0001)) / max(uVisibleViewportSize, vec2(1.0)); + vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0)); + vec3 horizontalEnd = sampleWallpaper(sourceUv + vec2(sourceTexel.x * 2.5, 0.0)); + vec3 verticalStart = sampleWallpaper(sourceUv - vec2(0.0, sourceTexel.y * 2.5)); + vec3 verticalEnd = sampleWallpaper(sourceUv + vec2(0.0, sourceTexel.y * 2.5)); + float contentGradient = max(length(horizontalEnd - horizontalStart), length(verticalEnd - verticalStart)); + + return mix(1.0, 0.44, smoothstep(0.07, 0.34, contentGradient)); +} + +vec2 softLimitDynamicRefraction(vec2 refraction) { + vec2 presentation = max(uPresentationSize, vec2(1.0)); + vec2 refractionPixels = refraction * presentation; + float limit = max(0.5, uMaxRefractionPixels); + float limitScale = limit / sqrt(limit * limit + dot(refractionPixels, refractionPixels)); + + return refractionPixels * limitScale / presentation; } void main() { float mask = 0.0; float edge = 0.0; float caustic = 0.0; - vec2 refraction = vec2(0.0); + float directionalReflection = 0.0; + float topPrism = 0.0; + float backlightAbsorption = 0.0; + float materialEnergy = 0.0; + vec2 staticRefraction = vec2(0.0); + vec2 dynamicRefraction = vec2(0.0); + vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0); + vec2 wakePerpendicular = vec2(-wakeDirection.y, wakeDirection.x); + vec2 trailRefraction = vec2(0.0); + float trailEnergy = 0.0; + + for (int trailIndex = 0; trailIndex < 4; trailIndex++) { + if (trailIndex >= uTrailCount) break; + + vec4 trail = uTrail[trailIndex]; + vec2 trailDelta = vUv - trail.xy; + trailDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0); + float along = dot(trailDelta, wakeDirection); + float across = dot(trailDelta, wakePerpendicular); + float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion); + float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion); + float lobe = + exp(-(along * along * trailAlongDensity + across * across * trailAcrossDensity)) * trail.z * uMotion; + float wake = mix(0.88, 0.58, float(trailIndex) / 3.0); + + trailRefraction += + (wakeDirection * 0.0048 + wakePerpendicular * across * 0.018) * + lobe * + uDeformationStrength * + uFlowStrength; + trailEnergy += lobe * wake * mix(0.72, 0.42, float(trailIndex) / 3.0); + } + + vec4 flowSample = uHasFlowTexture > 0.5 ? texture2D(uFlowTexture, vUv) : vec4(0.5, 0.5, 0.0, 1.0); + vec2 temporalFlow = + uHasFlowTexture > 0.5 + ? (flowSample.xy * 2.0 - 1.0) * + flowSample.z * + uMotion * + uDeformationStrength * + uFlowStrength + : vec2(0.0); + float temporalEnergy = uHasFlowTexture > 0.5 ? flowSample.z * uMotion : 0.0; + float flowSurfaceDetail = 0.0; + if (uQuality > 0.5 && uHasFlowTexture > 0.5) { + vec2 flowTexel = vec2(3.0) / max(uPresentationSize, vec2(1.0)); + vec3 flowLeft = texture2D(uFlowTexture, vUv - vec2(flowTexel.x, 0.0)).xyz; + vec3 flowRight = texture2D(uFlowTexture, vUv + vec2(flowTexel.x, 0.0)).xyz; + vec3 flowBottom = texture2D(uFlowTexture, vUv - vec2(0.0, flowTexel.y)).xyz; + vec3 flowTop = texture2D(uFlowTexture, vUv + vec2(0.0, flowTexel.y)).xyz; + float flowGradient = length(flowRight.xy - flowLeft.xy) + length(flowTop.xy - flowBottom.xy); + float energyGradient = abs(flowRight.z - flowLeft.z) + abs(flowTop.z - flowBottom.z); + flowSurfaceDetail = smoothstep(0.015, 0.24, flowGradient + energyGradient * 0.72) * uMotion; + } + float frosted = step(1.5, uAppearance); for (int i = 0; i < 8; i++) { if (i >= uRectCount) break; vec4 rect = uRects[i]; vec2 local = (vUv - rect.xy) / rect.zw; - float rectMask = roundedRectMask(local, rect.zw * uViewportSize, uRadii[i]); + float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i]; if (rectMask <= 0.0) continue; - float edgeDistance = min(min(local.x, 1.0 - local.x), min(local.y, 1.0 - local.y)); - float edgeResponse = 1.0 - smoothstep(0.0, 0.16, edgeDistance); + float sideDistance = min(local.x, 1.0 - local.x); + float nonBottomDistance = min(sideDistance, 1.0 - local.y); + float edgeResponse = 1.0 - smoothstep(0.0, 0.16, nonBottomDistance); vec2 lens = local - vec2(0.5); + vec2 lightDirection = normalize(vec2(-0.68, 0.74)); + float lightCoordinate = dot(lens, lightDirection); + float broadLight = + exp(-pow((lightCoordinate - 0.08) * 2.25, 2.0)) * + (0.42 + edgeResponse * 0.58); + float topEdge = 1.0 - smoothstep(0.0, 0.11, 1.0 - local.y); + float rightEdge = 1.0 - smoothstep(0.0, 0.14, 1.0 - local.x); + float bottomEdge = 1.0 - smoothstep(0.0, 0.14, local.y); + float litResponse = clamp(0.5 + lightCoordinate * 1.15, 0.0, 1.0); + float localDirectionalReflection = broadLight * mix(0.34, 1.0, litResponse) * rectMask; + float localTopPrism = topEdge * mix(0.38, 1.0, 1.0 - local.x) * rectMask; + float localBacklightAbsorption = max(rightEdge * 0.72, bottomEdge * 0.46) * rectMask; vec2 pointerDelta = uPointer - vUv; - float pointerEnergy = exp(-dot(pointerDelta, pointerDelta) * 18.0); - float ripple = sin((local.x + local.y) * 10.0 + uTime * 0.004) * mix(0.0008, 0.0014, uQuality); - vec2 pointerLocal = (uPointer - rect.xy) / rect.zw; - float causticDistance = length(local - pointerLocal); - float causticWave = 0.5 + 0.5 * cos(causticDistance * 34.0 - uTime * 0.018); - float localCaustic = exp(-causticDistance * 7.0) * causticWave * rectMask * uQuality; - - refraction += (lens * mix(0.006 + edgeResponse * 0.012, 0.009 + edgeResponse * 0.017, uQuality) + pointerDelta * pointerEnergy * mix(0.0024, 0.0042, uQuality) + ripple) * rectMask; + vec2 pointerDeltaAspect = pointerDelta; + pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0); + float pointerSpread = mix(mix(26.0, 17.0, uQuality), mix(12.0, 8.0, uQuality), frosted); + pointerSpread *= mix(1.0, 0.46, uMotionExpansion); + float pointerEnergy = + clamp(exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread) * uMotion, 0.0, 1.0); + vec2 wakeDelta = vUv - uPointer; + wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0); + float wakeAlong = dot(wakeDelta, wakeDirection); + float wakeAcross = dot(wakeDelta, wakePerpendicular); + float wakeTravel = + mix(0.014, 0.075, uWakeProgress) * + mix(0.82, 1.18, uQuality) * + mix(1.0, 1.45, uMotionExpansion); + float wakeWidth = mix(0.027, 0.044, uQuality) * mix(1.0, 1.72, uMotionExpansion); + float wakeCoordinate = (wakeAlong + wakeTravel) / wakeWidth; + float wakeShape = wakeCoordinate * exp(-0.5 * wakeCoordinate * wakeCoordinate); + float wakeEnvelope = + exp(-wakeAcross * wakeAcross * mix(280.0, 145.0, uQuality) * mix(1.0, 0.44, uMotionExpansion)); + vec2 wakeRefraction = + wakeDirection * + wakeShape * + wakeEnvelope * + mix(0.0045, 0.0075, uQuality) * + uMotion * + uDeformationStrength * + uFlowStrength; + float wakeEnergy = abs(wakeShape) * wakeEnvelope * uMotion; + float liquidEnergy = clamp(max( + pointerEnergy, + max(min(1.0, trailEnergy) * 0.68, max(temporalEnergy * 0.76, wakeEnergy * 0.82)) + ), 0.0, 1.0); + float staticLens = 0.00018 + edgeResponse * mix(0.0011, 0.0017, uQuality); + float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted); + float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted); + float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5); + vec2 specularDelta = vUv - (uPointer - wakeDirection * mix(0.006, 0.022, uMotionExpansion)); + specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0); + float specularAlong = dot(specularDelta, wakeDirection); + float specularAcross = dot(specularDelta, wakePerpendicular); + float singleSpecular = + exp(-( + specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) + + specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) + )) * + uMotion * + mix(1.0, 1.24, uMotionExpansion); + float localCaustic = singleSpecular * rectMask; + staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask; + vec2 sampleTranslation = + uPointerVelocity * + mix(0.055, 0.075, uQuality) * + uMotion * + uTranslationStrength; + dynamicRefraction += ( + sampleTranslation + + pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength + + trailRefraction * trailStrength + + temporalFlow * temporalStrength + + wakeRefraction + ) * rectMask; edge = max(edge, edgeResponse * rectMask); caustic = max(caustic, localCaustic); + directionalReflection = max(directionalReflection, localDirectionalReflection); + topPrism = max(topPrism, localTopPrism); + backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption); + materialEnergy = max(materialEnergy, liquidEnergy * rectMask); mask = max(mask, rectMask); } if (mask <= 0.0) discard; + float contentProtection = getContentProtection(coverUv(vUv + staticRefraction)); + dynamicRefraction *= contentProtection; + dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction); + vec2 refraction = staticRefraction + dynamicRefraction; vec2 sourceUv = coverUv(vUv + refraction); - float separation = 0.0012 * edge; - vec3 refracted = vec3( - texture2D(uTexture, sourceUv + vec2(separation, 0.0)).r, - texture2D(uTexture, sourceUv).g, - texture2D(uTexture, sourceUv - vec2(separation, 0.0)).b - ); - if (uQuality > 0.5) { - float detailSeparation = separation * 1.8 + 0.0007; - vec3 detailed = vec3( - texture2D(uTexture, sourceUv + vec2(detailSeparation, detailSeparation * 0.35)).r, - texture2D(uTexture, sourceUv).g, - texture2D(uTexture, sourceUv - vec2(detailSeparation, detailSeparation * 0.35)).b + float separation = edge * mix(0.00072, 0.0013, uQuality) * mix(1.0, 0.58, frosted); + vec3 refracted = sampleChromatic(sourceUv, separation); + float detailSeparation = separation * mix(1.45, 2.35, uQuality); + vec3 detailed = sampleChromatic(sourceUv, detailSeparation); + refracted = mix(refracted, detailed, mix(0.12, 0.32, uQuality) * (1.0 - frosted)); + vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular; + float diffusionRadius = + mix(0.0022, 0.0038, uQuality) * + ( + 0.82 + + materialEnergy * mix(0.28, 0.76, uMotionExpansion) + + flowSurfaceDetail * 0.38 ); - refracted = mix(refracted, detailed, 0.42); + vec3 diffused; + if (uQuality > 0.5) { + diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius); + } else { + diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius); } + refracted = mix(refracted, diffused, frosted); + float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722)); + float highlightBudget = mix(1.0, 0.34, smoothstep(0.48, 0.9, refractedLuminance)); + float frostedBrightCompression = smoothstep(0.58, 0.94, refractedLuminance) * frosted; + refracted *= 1.0 - frostedBrightCompression * mix(0.16, 0.22, uQuality); + vec3 frostedContrast = clamp((refracted - vec3(0.5)) * 1.16 + vec3(0.5), 0.0, 1.0); + refracted = mix( + refracted, + frostedContrast, + frosted * materialEnergy * mix(0.24, 0.58, uMotionExpansion) + ); vec3 highlight = vec3(0.84, 0.92, 1.0); float edgeHighlightMix = 0.12; - float causticHighlightMix = 0.12; - float materialAlpha = 0.28; + float causticHighlightMix = 0.075; + float liquidPresence = clamp(materialEnergy, 0.0, 1.0); + float materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence); float proceduralEdgeAlpha = 0.14; - float proceduralCausticAlpha = 0.1; + float proceduralCausticAlpha = 0.075; if (uAppearance > 1.5) { highlight = vec3(0.94, 0.97, 1.0); - edgeHighlightMix = 0.22; - causticHighlightMix = 0.1; - materialAlpha = 0.34; - proceduralEdgeAlpha = 0.18; - proceduralCausticAlpha = 0.08; + edgeHighlightMix = 0.15; + causticHighlightMix = 0.042; + materialAlpha = mix(0.78, 0.94, uTransparency) * mix(0.9, 1.0, liquidPresence); + proceduralEdgeAlpha = 0.16; + proceduralCausticAlpha = 0.045; } else if (uAppearance > 0.5) { highlight = mix(vec3(1.0), uTintColor, 0.72); edgeHighlightMix = 0.17; - causticHighlightMix = 0.16; - materialAlpha = 0.3; + causticHighlightMix = 0.085; + materialAlpha = uTransparency * mix(0.84, 1.0, liquidPresence); } if (uHasWallpaperTexture < 0.5) { - vec3 proceduralHighlight = highlight * (edge * 0.46 + caustic * 0.72); - float proceduralAlpha = mask * (edge * proceduralEdgeAlpha + caustic * proceduralCausticAlpha); + float proceduralReflection = + (directionalReflection * 0.42 + topPrism * 0.66 + caustic * 0.72) * + uReflectionStrength; + vec3 proceduralHighlight = highlight * proceduralReflection; + float proceduralAlpha = + mask * + ( + directionalReflection * proceduralEdgeAlpha + + topPrism * proceduralEdgeAlpha * 0.76 + + caustic * proceduralCausticAlpha + ) * + uReflectionStrength; gl_FragColor = vec4(proceduralHighlight, proceduralAlpha); return; } - refracted = mix(refracted, highlight, edge * edgeHighlightMix); - refracted += highlight * caustic * causticHighlightMix; + float reflectionMix = + clamp( + (directionalReflection * edgeHighlightMix + topPrism * edgeHighlightMix * 0.82) * + uReflectionStrength * + highlightBudget, + 0.0, + 0.36 + ); + float absorption = + clamp(backlightAbsorption * mix(0.035, 0.075, frosted) * uReflectionStrength, 0.0, 0.14); + refracted *= 1.0 - absorption; + refracted = mix(refracted, highlight, reflectionMix); + refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget; - gl_FragColor = vec4(refracted, mask * (materialAlpha + edge * 0.25 + caustic * 0.08)); + gl_FragColor = vec4( + refracted, + clamp( + mask * + ( + materialAlpha + + ( + directionalReflection * 0.065 + + topPrism * 0.05 + + caustic * mix(0.028, 0.04, uQuality) + ) * + uReflectionStrength * + highlightBudget + ), + 0.0, + 0.94 + ) + ); } ` const SCROLL_SURFACE_UPDATE_INTERVAL_MS = 32 +const FLOW_BUFFER_SCALE = 0.25 +const SURFACE_TRANSITION_DURATION_MS = 96 -/** 读取 CSS 圆角并折算为像素值。 */ -function readBorderRadius(element: HTMLElement) { - const value = Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) +/** 按 shader 协议读取视觉表面的四角圆角。 */ +function readBorderRadii(element: HTMLElement) { + const style = getComputedStyle(element) + const parseRadius = (value: string) => { + const radius = Number.parseFloat(value) - return Number.isFinite(value) ? value : 0 + return Number.isFinite(radius) ? radius : 0 + } + + return [ + parseRadius(style.borderTopLeftRadius), + parseRadius(style.borderTopRightRadius), + parseRadius(style.borderBottomRightRadius), + parseRadius(style.borderBottomLeftRadius), + ] as const } /** 判断元素是否在布局和视口中实际可见。 */ @@ -230,16 +719,18 @@ function isVisibleSurface(element: HTMLElement, bounds: DOMRect) { ) } -/** 将活动界面中的高价值材质面集中转换为 renderer 矩形预算。 */ -export function collectGlassOpticalRects( +/** 读取全部可见视觉表面,并保留 DOM 元素作为 renderer 生命周期内的稳定身份。 */ +function collectGlassOpticalSurfaceDescriptors( viewportWidth: number, viewportHeight: number, appearance: ThemeCustomizerGlassAppearance, + surfaceSpace: GlassPresentationSpace | 'all' = 'all', ) { - const candidates: GlassOpticalRect[] = [] + const candidates: Array = [] const seen = new Set() - for (const { rank, selector } of SURFACE_SELECTORS) { + for (const { rank, selector, space } of SURFACE_SELECTORS) { + if (surfaceSpace !== 'all' && space !== surfaceSpace) continue // 移动端透明顶栏只使用稳定的 CSS 表面,避免滚动重扫时再次叠加壁纸折射。 if (viewportWidth <= 600 && appearance === 'clear' && selector === '.layout-navbar') continue @@ -250,18 +741,66 @@ export function collectGlassOpticalRects( const bounds = element.getBoundingClientRect() if (!isVisibleSurface(element, bounds)) continue + const left = Math.max(0, bounds.left) + const top = Math.max(0, bounds.top) + const right = Math.min(viewportWidth, bounds.right) + 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 + const coordinateOffsetX = surfaceSpace === 'scroll' ? window.scrollX : 0 + const coordinateOffsetY = surfaceSpace === 'scroll' ? window.scrollY : 0 + candidates.push({ - height: bounds.height, - radius: readBorderRadius(element), - rank: rank + candidates.length * 0.001, - width: bounds.width, - x: bounds.left, - y: bounds.top, + key: element, + rect: { + height: bounds.height, + radii: [...readBorderRadii(element)] as GlassCornerRadii, + rank: rank + candidates.length * 0.001, + width: bounds.width, + x: bounds.left + coordinateOffsetX, + y: bounds.top + coordinateOffsetY, + }, + visibleArea: visibleWidth * visibleHeight, }) } } - return selectGlassOpticalRects(candidates, viewportWidth, viewportHeight, viewportWidth <= 600) + const selected: GlassOpticalSurfaceDescriptor[] = [] + for (const candidate of candidates.sort( + (left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea, + )) { + const { rect } = candidate + const nested = selected.some( + parent => + rect.x >= parent.rect.x && + rect.y >= parent.rect.y && + rect.x + rect.width <= parent.rect.x + parent.rect.width && + rect.y + rect.height <= parent.rect.y + parent.rect.height, + ) + if (!nested) selected.push({ key: candidate.key, rect }) + } + + return selected +} + +/** 将活动界面中的高价值材质面集中转换为 renderer 矩形预算。 */ +export function collectGlassOpticalRects( + viewportWidth: number, + viewportHeight: number, + appearance: ThemeCustomizerGlassAppearance, + interactionPoint?: { x: number; y: number }, + surfaceSpace: GlassPresentationSpace | 'all' = 'all', +) { + const candidates = collectGlassOpticalSurfaceDescriptors(viewportWidth, viewportHeight, appearance, surfaceSpace) + + return selectGlassOpticalRects( + candidates.map(candidate => candidate.rect), + viewportWidth, + viewportHeight, + viewportWidth <= 600, + interactionPoint, + ) } /** 将材质语义映射为稳定 shader 协议,质量档不参与该映射。 */ @@ -278,22 +817,79 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const renderedFrames = ref(0) let three: ThreeModule | null = null let resources: GlassRendererResources | null = null + let flowResources: GlassFlowResources | null = null let activeTexture: Texture | null = null let activeTextureHeight = 1 let activeTextureWidth = 1 + let previousTexture: Texture | null = null + let previousTextureHeight = 1 + let previousTextureWidth = 1 let loadVersion = 0 let animationFrame: number | null = null + let wallpaperTransitionFrame: number | null = null + let backgroundDisposeTimer: number | null = null + let presentationBufferHeight = 1 + let presentationBufferWidth = 1 let surfaceUpdateFrame: number | null = null - let pointerAnimationStartedAt = 0 + let lastInteractionAt = 0 + let lastInteractionFrameAt = 0 + let lastPointerAt = 0 + let lastTrailAt = 0 + let lastPointerX = window.innerWidth * 0.5 + let lastPointerY = window.innerHeight * 0.5 + let pointerTargetX = 0.5 + let pointerTargetY = 0.5 + let pointerPositionX = 0.5 + let pointerPositionY = 0.5 + let pointerSpringVelocityX = 0 + let pointerSpringVelocityY = 0 + let activeTouchIdentifier: number | null = null + let pendingFlowInjection = 0 + let interactionAnimating = false let surfaceResizeTimer: number | null = null let scrollSurfaceTimer: number | null = null + let unsubscribeInteractionSource: (() => void) | null = null let lastScrollSurfaceUpdateAt = 0 let resizeObserver: ResizeObserver | null = null let surfaceMutationObserver: MutationObserver | null = null let observedSurfaces: HTMLElement[] = [] - let currentRects: GlassOpticalRect[] = [] + let availableSurfaces: GlassOpticalSurfaceDescriptor[] = [] + let surfaceSlots: GlassOpticalSurfaceSlot[] = [] + let activeSurface: HTMLElement | null = null + let outgoingSurface: HTMLElement | null = null + let surfaceTransitionStartedAt = 0 + let wakeDirection = { x: 0, y: -1 } let tracksScrollingSurfaces = false let contextRecoveryPending = false + const presentationSpace = options.surfaceSpace ?? 'fixed' + + function updateRendererState(value: GlassRendererState) { + if (options.syncDocumentState === false) { + state.value = value + return + } + + setGlassRendererState(state, value) + } + + function getPresentationSize() { + if (presentationSpace === 'fixed') { + return { height: window.innerHeight, width: window.innerWidth } + } + + const root = options.canvas.value?.parentElement + + return { + height: Math.max(window.innerHeight, root?.scrollHeight ?? 0, document.documentElement.scrollHeight), + width: Math.max(window.innerWidth, root?.scrollWidth ?? 0, document.documentElement.scrollWidth), + } + } + + function getPresentationPoint(clientX: number, clientY: number) { + if (presentationSpace === 'fixed') return { x: clientX, y: clientY } + + return { x: clientX + window.scrollX, y: clientY + window.scrollY } + } function cancelScheduledFrame() { if (animationFrame === null) return @@ -302,43 +898,243 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) animationFrame = null } + function cancelWallpaperTransitionFrame() { + if (wallpaperTransitionFrame === null) return + + cancelAnimationFrame(wallpaperTransitionFrame) + wallpaperTransitionFrame = null + } + + function clearBackgroundDisposeTimer() { + if (backgroundDisposeTimer === null) return + + window.clearTimeout(backgroundDisposeTimer) + backgroundDisposeTimer = null + } + + /** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */ + function finishWallpaperTransition() { + if (!resources || !activeTexture) return + + if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose() + previousTexture = null + previousTextureHeight = activeTextureHeight + previousTextureWidth = activeTextureWidth + resources.uniforms.uPreviousTexture.value = activeTexture + resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value) + resources.uniforms.uTextureMix.value = 1 + cancelWallpaperTransitionFrame() + } + + /** 使用外层壁纸的 CSS ease 时钟推进双纹理,不创建常驻动画帧。 */ + function updateWallpaperTransition(timestamp: number) { + if (!resources || !previousTexture) return false + + const startedAt = toValue(options.transitionStartedAt ?? 0) + const duration = toValue(options.transitionDuration ?? 0) + const progress = getGlassWallpaperTransitionProgress(timestamp - startedAt, duration) + resources.uniforms.uTextureMix.value = progress + if (progress >= 1) { + finishWallpaperTransition() + return false + } + + return true + } + + function renderWallpaperTransitionFrame(timestamp: number) { + wallpaperTransitionFrame = null + if (document.visibilityState === 'hidden') return + + renderFrame(timestamp) + if (previousTexture && wallpaperTransitionFrame === null) { + wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame) + } + } + + function scheduleWallpaperTransition() { + if (wallpaperTransitionFrame !== null || !previousTexture || document.visibilityState === 'hidden') return + + wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame) + } + + /** 释放仅由高质量档使用的短时液态位移场。 */ + function disposeFlowResources() { + if (!flowResources) return + + flowResources.material.dispose() + flowResources.readTarget.dispose() + flowResources.writeTarget.dispose() + flowResources = null + if (resources) { + resources.uniforms.uFlowTexture.value = null + resources.uniforms.uHasFlowTexture.value = 0 + } + } + + /** 根据当前质量档创建或释放共享 renderer 内的液态位移场。 */ + function syncFlowResources() { + if (!resources || !three) return + + const profile = getRenderProfile() + if (!profile.flowField) { + disposeFlowResources() + return + } + if (flowResources) return + + const createTarget = () => + new three!.WebGLRenderTarget(1, 1, { + depthBuffer: false, + magFilter: three!.LinearFilter, + minFilter: three!.LinearFilter, + stencilBuffer: false, + }) + const uniforms: GlassFlowUniforms = { + uDecay: { value: 1 }, + uInjection: { value: 0 }, + uPointer: { value: resources.uniforms.uPointer.value }, + uPrevious: { value: null }, + uTexelSize: { value: new three.Vector2(1, 1) }, + uVelocity: { value: resources.uniforms.uPointerVelocity.value }, + uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) }, + } + const material = new three.ShaderMaterial({ + depthTest: false, + depthWrite: false, + fragmentShader: FLOW_FRAGMENT_SHADER, + uniforms, + vertexShader: VERTEX_SHADER, + }) + const scene = new three.Scene() + const mesh = new three.Mesh(resources.geometry, material) + mesh.frustumCulled = false + scene.add(mesh) + flowResources = { + material, + mesh, + readTarget: createTarget(), + scene, + uniforms, + writeTarget: createTarget(), + } + resources.uniforms.uHasFlowTexture.value = 1 + } + function renderFrame(timestamp = performance.now()) { - animationFrame = null if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return - resources.uniforms.uTime.value = timestamp + updateWallpaperTransition(timestamp) + if (flowResources) { + resources.renderer.setScissorTest(false) + flowResources.uniforms.uPrevious.value = flowResources.readTarget.texture + resources.renderer.setRenderTarget(flowResources.writeTarget) + resources.renderer.render(flowResources.scene, resources.camera) + resources.renderer.setRenderTarget(null) + const previousReadTarget = flowResources.readTarget + flowResources.readTarget = flowResources.writeTarget + flowResources.writeTarget = previousReadTarget + resources.uniforms.uFlowTexture.value = flowResources.readTarget.texture + } + if (presentationSpace === 'scroll') { + const { height: presentationHeight } = getPresentationSize() + const scaleY = presentationBufferHeight / Math.max(presentationHeight, 1) + const scissorY = Math.max(0, Math.floor((presentationHeight - window.scrollY - window.innerHeight) * scaleY)) + const scissorHeight = Math.min(presentationBufferHeight - scissorY, Math.ceil(window.innerHeight * scaleY) + 2) + resources.renderer.setScissorTest(true) + resources.renderer.setScissor(0, scissorY, presentationBufferWidth, scissorHeight) + resources.renderer.clear() + } else { + resources.renderer.setScissorTest(false) + } resources.renderer.render(resources.scene, resources.camera) renderedFrames.value += 1 } + function renderScheduledFrame(timestamp: number) { + animationFrame = null + renderFrame(timestamp) + } + function scheduleFrame() { if (animationFrame !== null || !resources) return - animationFrame = requestAnimationFrame(renderFrame) + animationFrame = requestAnimationFrame(renderScheduledFrame) } - function updateSurfaceUniforms() { - if (!resources || !three) return + /** 将稳定槽位及其短时交叉权重写入固定长度的 shader 协议。 */ + function writeSurfaceUniforms(timestamp = performance.now()) { + if (!resources) return - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight - const rects = collectGlassOpticalRects(viewportWidth, viewportHeight, toValue(options.appearance)) - currentRects = rects - const normalized = rects.map(rect => normalizeGlassOpticalRect(rect, viewportWidth, viewportHeight)) + if (outgoingSurface && timestamp - surfaceTransitionStartedAt >= SURFACE_TRANSITION_DURATION_MS) { + outgoingSurface = null + surfaceSlots = surfaceSlots + .filter(slot => slot.role !== 'outgoing') + .map(slot => ({ + ...slot, + role: slot.key === activeSurface ? ('active' as const) : ('stable' as const), + })) + } + + const presentation = getPresentationSize() + const normalized = surfaceSlots.map(slot => + normalizeGlassOpticalRect(slot.rect, presentation.width, presentation.height), + ) const uniformRects = resources.uniforms.uRects.value const uniformRadii = resources.uniforms.uRadii.value + const uniformWeights = resources.uniforms.uSurfaceWeights.value + const transitionWeights = outgoingSurface + ? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS) + : { incoming: 1, outgoing: 0 } for (let index = 0; index < 8; index += 1) { const surface = normalized[index] + const slot = surfaceSlots[index] const rect = surface?.rect ?? [0, 0, 0, 0] + const radii = surface?.radii ?? [0, 0, 0, 0] uniformRects[index].set(rect[0], rect[1], rect[2], rect[3]) - uniformRadii[index] = surface?.radius ?? 0 + uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3]) + uniformWeights[index] = + slot?.role === 'outgoing' + ? transitionWeights.outgoing + : slot?.role === 'active' + ? transitionWeights.incoming + : slot + ? 1 + : 0 } resources.uniforms.uRectCount.value = normalized.length + } + + function updateSurfaceUniforms(timestamp = performance.now()) { + if (!resources) return + + const viewportWidth = window.innerWidth + availableSurfaces = collectGlassOpticalSurfaceDescriptors( + viewportWidth, + window.innerHeight, + toValue(options.appearance), + presentationSpace, + ) + const availableKeys = new Set(availableSurfaces.map(surface => surface.key)) + if (activeSurface && !availableKeys.has(activeSurface)) activeSurface = null + if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null + const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP + surfaceSlots = reconcileGlassOpticalSurfaceSlots( + surfaceSlots, + availableSurfaces, + maxCount, + activeSurface ?? undefined, + outgoingSurface ?? undefined, + ) + writeSurfaceUniforms(timestamp) + const nextObservedSurfaces = Array.from( new Set( - SURFACE_SELECTORS.flatMap(({ selector }) => Array.from(document.querySelectorAll(selector))), + SURFACE_SELECTORS.filter(({ space }) => space === presentationSpace).flatMap(({ selector }) => + Array.from(document.querySelectorAll(selector)), + ), ), ) const observedSurfacesChanged = @@ -350,7 +1146,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) observedSurfaces = nextObservedSurfaces for (const element of observedSurfaces) resizeObserver?.observe(element) } - tracksScrollingSurfaces = observedSurfaces.some(element => element.matches('.dashboard-grid-item-content')) + tracksScrollingSurfaces = observedSurfaces.length > 0 scheduleFrame() } @@ -368,7 +1164,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) surfaceResizeTimer = window.setTimeout(() => { surfaceResizeTimer = null - scheduleSurfaceUpdate() + if (presentationSpace === 'scroll') resizeRenderer() + else scheduleSurfaceUpdate() }, 160) } @@ -376,22 +1173,89 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (!resources) return const cover = getGlassCoverScale(viewportWidth, viewportHeight, activeTextureWidth, activeTextureHeight) + const previousCover = getGlassCoverScale(viewportWidth, viewportHeight, previousTextureWidth, previousTextureHeight) resources.uniforms.uCoverScale.value.set(cover.x, cover.y) + resources.uniforms.uPreviousCoverScale.value.set(previousCover.x, previousCover.y) } function getRenderProfile(routeKey = toValue(options.routeKey)) { return getGlassOpticalRenderProfile(toValue(options.quality), routeKey) } + function getLegacyDynamicStrength() { + return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT) + } + + function getTranslationStrengthScale() { + return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength())) + } + + function getDeformationStrengthScale() { + return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength())) + } + + function getFlowStrengthScale() { + return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength())) + } + + function getMotionExpansion() { + return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength())) + } + + function getMaxRefractionPixels() { + return getGlassOpticalMaxRefractionPixels( + getRenderProfile().maxRefractionPixels, + toValue(options.deformationStrength ?? getLegacyDynamicStrength()), + ) + } + + function getReflectionStrengthScale() { + return getGlassOpticalReflectionStrengthScale(toValue(options.reflectionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT)) + } + + function getTransparency() { + return getGlassOpticalTransparency(toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT)) + } + function resizeRenderer() { if (!resources) return const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight + const presentation = getPresentationSize() const profile = getRenderProfile() - const buffer = getGlassOpticalBufferSize(viewportWidth, viewportHeight, viewportWidth <= 600, profile.bufferQuality) + const buffer = + presentationSpace === 'scroll' + ? getGlassScrollBufferSize( + presentation.width, + presentation.height, + profile.bufferQuality, + window.devicePixelRatio, + ) + : getGlassOpticalBufferSize( + viewportWidth, + viewportHeight, + viewportWidth <= 600, + profile.bufferQuality, + window.devicePixelRatio, + ) + presentationBufferHeight = buffer.height + presentationBufferWidth = buffer.width resources.renderer.setSize(buffer.width, buffer.height, false) - resources.uniforms.uViewportSize.value.set(viewportWidth, viewportHeight) + resources.uniforms.uVisibleViewportSize.value.set(viewportWidth, viewportHeight) + resources.uniforms.uPresentationSize.value.set(presentation.width, presentation.height) + resources.uniforms.uScrollOffset.value.set( + presentationSpace === 'scroll' ? window.scrollX : 0, + presentationSpace === 'scroll' ? window.scrollY : 0, + ) + if (flowResources) { + const flowWidth = Math.max(96, Math.round(buffer.width * FLOW_BUFFER_SCALE)) + const flowHeight = Math.max(96, Math.round(buffer.height * FLOW_BUFFER_SCALE)) + flowResources.readTarget.setSize(flowWidth, flowHeight) + flowResources.writeTarget.setSize(flowWidth, flowHeight) + flowResources.uniforms.uTexelSize.value.set(1 / flowWidth, 1 / flowHeight) + flowResources.uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1) + } syncCoverScale(viewportWidth, viewportHeight) scheduleSurfaceUpdate() } @@ -406,38 +1270,361 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) ) } - function handlePointerMove(event: PointerEvent) { - if (!resources || matchMedia('(prefers-reduced-motion: reduce)').matches) return - if ( - !currentRects.some( - rect => - event.clientX >= rect.x && - event.clientX <= rect.x + rect.width && - event.clientY >= rect.y && - event.clientY <= rect.y + rect.height, - ) - ) - return + function rectContainsPoint(rect: GlassOpticalRect, x: number, y: number) { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height + } - resources.uniforms.uPointer.value.set(event.clientX / window.innerWidth, 1 - event.clientY / window.innerHeight) - pointerAnimationStartedAt = performance.now() + function findInteractionSurface(x: number, y: number) { + if (availableSurfaces.length === 0) updateSurfaceUniforms() - const animatePointer = (timestamp: number) => { - renderFrame(timestamp) - const responseDuration = toValue(options.quality) === 'high' ? 520 : 360 - if (timestamp - pointerAnimationStartedAt < responseDuration && toValue(options.active)) { - animationFrame = requestAnimationFrame(animatePointer) - } + return availableSurfaces.find(surface => rectContainsPoint(surface.rect, x, y)) ?? null + } + + /** + * 跨表面移动只交接活动身份,不重排其余槽位或让轨迹穿过卡片间隙。 + * 已在预算内的表面保持完整权重;只有预算外表面进入时才让被替换表面单调淡出。 + */ + function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) { + if (activeSurface === surface) { + writeSurfaceUniforms(timestamp) + return false } + const surfaceAlreadyHasSlot = surfaceSlots.some(slot => slot.key === surface) + 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, + availableSurfaces, + maxCount, + activeSurface, + outgoingSurface ?? undefined, + ) + writeSurfaceUniforms(timestamp) + + return true + } + + /** 让触点保持直接反馈,精细指针则以受控惯性跟随输入目标。 */ + function updatePointerTarget(x: number, y: number, response: number) { + if (!resources) return + + pointerTargetX = x + pointerTargetY = y + const clampedResponse = Math.min(1, Math.max(0, response)) + pointerPositionX += (pointerTargetX - pointerPositionX) * clampedResponse + pointerPositionY += (pointerTargetY - pointerPositionY) * clampedResponse + if (clampedResponse >= 1) { + pointerSpringVelocityX = 0 + pointerSpringVelocityY = 0 + } + resources.uniforms.uPointer.value.set(pointerPositionX, pointerPositionY) + } + + function snapPointer(x: number, y: number) { + pointerTargetX = x + pointerTargetY = y + pointerPositionX = x + pointerPositionY = y + pointerSpringVelocityX = 0 + pointerSpringVelocityY = 0 + resources?.uniforms.uPointer.value.set(x, y) + } + + /** 快速输入在单次事件内补齐视口路径,避免事件稀疏时只留下孤立触点。 */ + function updateTrail( + x: number, + y: number, + timestamp: number, + previousNormalizedX: number, + previousNormalizedY: number, + ) { + if (!resources) return + + const presentation = getPresentationSize() + const normalizedX = x / Math.max(presentation.width, 1) + const normalizedY = 1 - y / Math.max(presentation.height, 1) + const distance = Math.hypot(normalizedX - previousNormalizedX, normalizedY - previousNormalizedY) + if (timestamp - lastTrailAt < 36 && distance < 0.012) return + + const trail = resources.uniforms.uTrail.value + const interpolatedSamples = Math.min(getRenderProfile().trailCount, Math.max(1, Math.ceil(distance / 0.045))) + if (interpolatedSamples > 1) { + for (let index = 0; index < trail.length; index += 1) { + if (index >= interpolatedSamples) { + trail[index].z = 0 + continue + } + + const progress = index / (interpolatedSamples - 1) + trail[index].set( + normalizedX + (previousNormalizedX - normalizedX) * progress, + normalizedY + (previousNormalizedY - normalizedY) * progress, + 1 - progress * 0.28, + 0, + ) + } + lastTrailAt = timestamp + return + } + + for (let index = trail.length - 1; index > 0; index -= 1) { + trail[index].copy(trail[index - 1]) + trail[index].z *= 0.74 + } + trail[0].set(normalizedX, normalizedY, 1, 0) + lastTrailAt = timestamp + } + + /** 清除短时交互能量,确保静止帧不保留时序流场或输入方向。 */ + function resetInteractionState() { + pendingFlowInjection = 0 + lastInteractionFrameAt = 0 + if (!resources) return + + snapPointer(pointerTargetX, pointerTargetY) + resources.uniforms.uMotion.value = 0 + resources.uniforms.uPointerVelocity.value.set(0, 0) + resources.uniforms.uWakeProgress.value = 1 + for (const trail of resources.uniforms.uTrail.value) trail.z = 0 + if (flowResources) { + flowResources.uniforms.uDecay.value = 0 + flowResources.uniforms.uInjection.value = 0 + } + } + + function renderInteractionFrame(timestamp: number) { + if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') { + animationFrame = null + interactionAnimating = false + return + } + + const profile = getRenderProfile() + const elapsed = Math.max(0, timestamp - lastInteractionAt) + const flowScale = getFlowStrengthScale() + const durationScale = Math.max(0.35, 0.55 + flowScale * 0.45) + const motionDuration = profile.motionDuration * durationScale + const motionHalfLife = profile.motionHalfLife * durationScale + const motion = getGlassOpticalMotionEnergy(elapsed, motionDuration, motionHalfLife) + writeSurfaceUniforms(timestamp) + resources.uniforms.uWakeProgress.value = Math.min(1, elapsed / Math.max(1, motionDuration)) + if (motion <= 0) { + resetInteractionState() + renderFrame() + interactionAnimating = false + return + } + + const delta = lastInteractionFrameAt > 0 ? Math.min(64, Math.max(0, timestamp - lastInteractionFrameAt)) : 16.67 + lastInteractionFrameAt = timestamp + const horizontalSpring = stepGlassOpticalSpring( + { position: pointerPositionX, velocity: pointerSpringVelocityX }, + pointerTargetX, + delta, + profile.springFrequency, + profile.springDamping, + ) + const verticalSpring = stepGlassOpticalSpring( + { position: pointerPositionY, velocity: pointerSpringVelocityY }, + pointerTargetY, + delta, + profile.springFrequency, + profile.springDamping, + ) + pointerPositionX = horizontalSpring.position + pointerPositionY = verticalSpring.position + pointerSpringVelocityX = horizontalSpring.velocity + pointerSpringVelocityY = verticalSpring.velocity + resources.uniforms.uPointer.value.set(pointerPositionX, pointerPositionY) + // 收敛期只衰减能量;输入方向保留到最终清场,避免低能量归一化时发生方向跳变。 + resources.uniforms.uMotion.value = motion + if (flowResources) { + flowResources.uniforms.uDecay.value = getGlassOpticalDecay(profile.flowHalfLife, delta) + flowResources.uniforms.uInjection.value = pendingFlowInjection + } + pendingFlowInjection = 0 + renderFrame() + if (flowResources) flowResources.uniforms.uInjection.value = 0 + animationFrame = requestAnimationFrame(renderInteractionFrame) + } + + function startInteractionAnimation() { + if (interactionAnimating) return + cancelScheduledFrame() - animationFrame = requestAnimationFrame(animatePointer) + interactionAnimating = true + lastInteractionFrameAt = 0 + animationFrame = requestAnimationFrame(renderInteractionFrame) + } + + /** 将鼠标、触摸或滚动输入转换为共享液态状态。 */ + function applyInteraction( + clientX: number, + clientY: number, + timestamp: number, + velocityOverride?: { x: number; y: number }, + ) { + const viewportWidth = Math.max(window.innerWidth, 1) + const viewportHeight = Math.max(window.innerHeight, 1) + const presentation = getPresentationSize() + const point = getPresentationPoint(clientX, clientY) + const previousPoint = getPresentationPoint(lastPointerX, lastPointerY) + const elapsed = Math.max(8, timestamp - lastPointerAt) + const previousNormalizedX = previousPoint.x / Math.max(presentation.width, 1) + const previousNormalizedY = 1 - previousPoint.y / Math.max(presentation.height, 1) + const velocityX = velocityOverride?.x ?? ((clientX - lastPointerX) / viewportWidth) * Math.min(2, 16.67 / elapsed) + const velocityY = velocityOverride?.y ?? (-(clientY - lastPointerY) / viewportHeight) * Math.min(2, 16.67 / elapsed) + const velocityLength = Math.hypot(velocityX, velocityY) + const velocityScale = velocityLength > 0.09 ? 0.09 / velocityLength : 1 + const surface = findInteractionSurface(point.x, point.y) + lastPointerX = clientX + lastPointerY = clientY + lastPointerAt = timestamp + if (!resources || !surface) return + + const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches + const surfaceChanged = activateInteractionSurface(surface.key, timestamp, reducedMotion) + const restartsWake = surfaceChanged || !interactionAnimating + const normalizedX = point.x / Math.max(presentation.width, 1) + const normalizedY = 1 - point.y / Math.max(presentation.height, 1) + if (surfaceChanged) { + snapPointer(normalizedX, normalizedY) + updateTrail(point.x, point.y, timestamp, normalizedX, normalizedY) + } else { + updateTrail(point.x, point.y, timestamp, previousNormalizedX, previousNormalizedY) + const profile = getRenderProfile() + const directTouchResponse = + window.innerWidth <= 600 || matchMedia('(pointer: coarse)').matches || activeTouchIdentifier !== null + updatePointerTarget(normalizedX, normalizedY, directTouchResponse ? 1 : profile.pointerImmediateResponse) + } + const scaledVelocity = { x: velocityX * velocityScale, y: velocityY * velocityScale } + wakeDirection = getGlassOpticalWakeDirection( + wakeDirection, + scaledVelocity, + Math.hypot(scaledVelocity.x, scaledVelocity.y), + restartsWake, + ) + resources.uniforms.uPointerVelocity.value.set(scaledVelocity.x, scaledVelocity.y) + resources.uniforms.uWakeDirection.value.set(wakeDirection.x, wakeDirection.y) + resources.uniforms.uWakeProgress.value = 0 + resources.uniforms.uTrailCount.value = getRenderProfile().trailCount + lastInteractionAt = timestamp + + if (reducedMotion) { + resetInteractionState() + scheduleFrame() + return + } + + resources.uniforms.uMotion.value = 1 + if (getFlowStrengthScale() <= 0) { + interactionAnimating = false + cancelScheduledFrame() + 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 + } + scheduleFrame() + return + } + + pendingFlowInjection = Math.max(pendingFlowInjection, Math.min(1, velocityLength * 18)) + startInteractionAnimation() + } + + function handlePointerMove(event: PointerEvent) { + if (event.pointerType === 'touch') return + + applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now()) + } + + function findTouch(touches: TouchList, identifier: number | null) { + for (let index = 0; index < touches.length; index += 1) { + const touch = touches.item(index) + if (touch && (identifier === null || touch.identifier === identifier)) return touch + } + + return null + } + + /** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */ + function handleTouchStart(event: TouchEvent) { + if (activeTouchIdentifier !== null) return + + const touch = findTouch(event.changedTouches, null) + if (!touch) return + + const timestamp = event.timeStamp || performance.now() + activeTouchIdentifier = touch.identifier + lastPointerX = touch.clientX + lastPointerY = touch.clientY + lastPointerAt = timestamp + const point = getPresentationPoint(touch.clientX, touch.clientY) + const presentation = getPresentationSize() + const surface = findInteractionSurface(point.x, point.y) + if (resources && surface) { + activateInteractionSurface(surface.key, timestamp, matchMedia('(prefers-reduced-motion: reduce)').matches) + snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1)) + scheduleFrame() + } + } + + function handleTouchMove(event: TouchEvent) { + const touch = findTouch(event.touches, activeTouchIdentifier) + if (!touch) return + + const timestamp = event.timeStamp || performance.now() + applyInteraction(touch.clientX, touch.clientY, timestamp) + } + + function handleTouchEnd(event: TouchEvent) { + if (!findTouch(event.changedTouches, activeTouchIdentifier)) return + + activeTouchIdentifier = null + } + + function handleInteractionEvent(event: PointerEvent | TouchEvent) { + if (event.type === 'pointermove') { + handlePointerMove(event as PointerEvent) + return + } + if (event.type === 'touchstart') { + handleTouchStart(event as TouchEvent) + return + } + if (event.type === 'touchmove') { + handleTouchMove(event as TouchEvent) + return + } + + handleTouchEnd(event as TouchEvent) } function handleScroll() { + if (presentationSpace === 'scroll' && resources) { + resources.uniforms.uScrollOffset.value.set(window.scrollX, window.scrollY) + scheduleFrame() + return + } + + const timestamp = performance.now() if (!tracksScrollingSurfaces || scrollSurfaceTimer !== null) return - const elapsed = performance.now() - lastScrollSurfaceUpdateAt + const elapsed = timestamp - lastScrollSurfaceUpdateAt const delay = Math.max(0, SCROLL_SURFACE_UPDATE_INTERVAL_MS - elapsed) scrollSurfaceTimer = window.setTimeout(() => { scrollSurfaceTimer = null @@ -446,16 +1633,60 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) }, delay) } + /** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */ + function pauseRenderer() { + cancelScheduledFrame() + cancelWallpaperTransitionFrame() + interactionAnimating = false + } + + /** 恢复时先同步尺寸和表面,再立即绘制不含旧惯性的稳定首帧。 */ + async function resumeRenderer() { + clearBackgroundDisposeTimer() + if (!toValue(options.active) || document.visibilityState === 'hidden') return + + await nextTick() + if (!resources) { + await initializeRenderer() + return + } + + resizeRenderer() + updateSurfaceUniforms() + resetInteractionState() + renderFrame(performance.now()) + scheduleWallpaperTransition() + } + function handleVisibilityChange() { - if (document.visibilityState === 'hidden') cancelScheduledFrame() - else scheduleFrame() + if (document.visibilityState === 'hidden') { + pauseRenderer() + clearBackgroundDisposeTimer() + backgroundDisposeTimer = window.setTimeout(() => { + backgroundDisposeTimer = null + if (document.visibilityState !== 'hidden') return + + disposeRenderer() + }, APP_ACTIVITY_SUSPEND_DELAY_MS) + return + } + + void resumeRenderer() + } + + function handleWindowBlur() { + if (document.visibilityState === 'visible') pauseRenderer() + } + + function handleWindowResume() { + if (document.visibilityState === 'visible') void resumeRenderer() } function handleContextLost(event: Event) { event.preventDefault() cancelScheduledFrame() contextRecoveryPending = true - setGlassRendererState(state, 'fallback') + updateRendererState('fallback') } function handleContextRestored() { @@ -500,19 +1731,33 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } function setupEvents() { - window.addEventListener('pointermove', handlePointerMove, { passive: true }) + if (options.interactionSource) { + unsubscribeInteractionSource = options.interactionSource.subscribe(handleInteractionEvent) + } else { + window.addEventListener('pointermove', handlePointerMove, { passive: true }) + window.addEventListener('touchstart', handleTouchStart, { passive: true }) + window.addEventListener('touchmove', handleTouchMove, { passive: true }) + window.addEventListener('touchend', handleTouchEnd, { passive: true }) + window.addEventListener('touchcancel', handleTouchEnd, { passive: true }) + } window.addEventListener('resize', resizeRenderer, { passive: true }) window.addEventListener('scroll', handleScroll, { capture: true, passive: true }) - document.addEventListener('visibilitychange', handleVisibilityChange) options.canvas.value?.addEventListener('webglcontextlost', handleContextLost) options.canvas.value?.addEventListener('webglcontextrestored', handleContextRestored) } function removeEvents() { - window.removeEventListener('pointermove', handlePointerMove) + unsubscribeInteractionSource?.() + unsubscribeInteractionSource = null + if (!options.interactionSource) { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + window.removeEventListener('touchcancel', handleTouchEnd) + } window.removeEventListener('resize', resizeRenderer) window.removeEventListener('scroll', handleScroll, true) - document.removeEventListener('visibilitychange', handleVisibilityChange) options.canvas.value?.removeEventListener('webglcontextlost', handleContextLost) options.canvas.value?.removeEventListener('webglcontextrestored', handleContextRestored) } @@ -520,7 +1765,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) function disposeRenderer(releaseContext = true) { loadVersion += 1 contextRecoveryPending = false + interactionAnimating = false cancelScheduledFrame() + cancelWallpaperTransitionFrame() + clearBackgroundDisposeTimer() if (surfaceUpdateFrame !== null) { cancelAnimationFrame(surfaceUpdateFrame) surfaceUpdateFrame = null @@ -539,13 +1787,35 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) surfaceMutationObserver?.disconnect() surfaceMutationObserver = null observedSurfaces = [] - currentRects = [] + availableSurfaces = [] + surfaceSlots = [] + activeSurface = null + outgoingSurface = null + surfaceTransitionStartedAt = 0 + wakeDirection = { x: 0, y: -1 } tracksScrollingSurfaces = false + document.documentElement.removeAttribute('data-glass-wallpaper-loading') + activeTouchIdentifier = null + lastPointerX = window.innerWidth * 0.5 + lastPointerY = window.innerHeight * 0.5 + pointerTargetX = 0.5 + pointerTargetY = 0.5 + pointerPositionX = 0.5 + pointerPositionY = 0.5 + pointerSpringVelocityX = 0 + pointerSpringVelocityY = 0 + pendingFlowInjection = 0 + lastInteractionFrameAt = 0 + if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose() + previousTexture = null + previousTextureHeight = 1 + previousTextureWidth = 1 activeTexture?.dispose() activeTexture = null activeTextureHeight = 1 activeTextureWidth = 1 + disposeFlowResources() if (resources) { resources.geometry.dispose() resources.material.dispose() @@ -563,7 +1833,77 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) console.warn(message, error) disposeRenderer() - setGlassRendererState(state, 'fallback') + updateRendererState('fallback') + } + + /** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */ + async function refreshWallpaper(message: string) { + const version = ++loadVersion + const retainsActiveTexture = Boolean(resources && activeTexture) + if (!retainsActiveTexture) updateRendererState('loading') + document.documentElement.setAttribute('data-glass-wallpaper-loading', 'true') + + try { + await loadWallpaper(toValue(options.wallpaperUrl), version) + } catch (error) { + if (version !== loadVersion) return + if (retainsActiveTexture && resources && activeTexture) { + console.warn(message, error) + updateRendererState('ready') + scheduleFrame() + return + } + fallbackFromCurrentLoad(version, message, error) + } finally { + if (version === loadVersion) document.documentElement.removeAttribute('data-glass-wallpaper-loading') + } + } + + /** 在同一 renderer 内交接新旧纹理;只有真实壁纸轮换才启用双纹理时钟。 */ + function activateLoadedTexture(texture: Texture, width: number, height: number, hasWallpaperTexture: boolean) { + if (!resources) { + texture.dispose() + return + } + + const hasActiveTransition = + Boolean(activeTexture) && + Boolean(toValue(options.previousWallpaperUrl ?? '')) && + toValue(options.transitionStartedAt ?? 0) > 0 + cancelWallpaperTransitionFrame() + + if (hasActiveTransition && activeTexture) { + if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose() + previousTexture = activeTexture + previousTextureHeight = activeTextureHeight + previousTextureWidth = activeTextureWidth + activeTexture = texture + activeTextureHeight = height + activeTextureWidth = width + resources.uniforms.uPreviousTexture.value = previousTexture + resources.uniforms.uTexture.value = activeTexture + resources.uniforms.uTextureMix.value = getGlassWallpaperTransitionProgress( + performance.now() - toValue(options.transitionStartedAt ?? 0), + toValue(options.transitionDuration ?? 0), + ) + syncCoverScale() + scheduleWallpaperTransition() + } else { + if (previousTexture && previousTexture !== activeTexture) previousTexture.dispose() + activeTexture?.dispose() + previousTexture = null + activeTexture = texture + activeTextureHeight = height + activeTextureWidth = width + previousTextureHeight = height + previousTextureWidth = width + resources.uniforms.uPreviousTexture.value = texture + resources.uniforms.uTexture.value = texture + resources.uniforms.uTextureMix.value = 1 + syncCoverScale() + } + + resources.uniforms.uHasWallpaperTexture.value = hasWallpaperTexture ? 1 : 0 } async function loadWallpaper(url: string, version: number) { @@ -582,16 +1922,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) texture.generateMipmaps = false texture.minFilter = three.LinearFilter texture.magFilter = three.LinearFilter - activeTexture?.dispose() - activeTexture = texture - activeTextureHeight = 1 - activeTextureWidth = 1 - resources.uniforms.uTexture.value = texture - resources.uniforms.uHasWallpaperTexture.value = 0 + activateLoadedTexture(texture, 1, 1, false) await resources.renderer.compileAsync(resources.scene, resources.camera) if (version !== loadVersion || !resources) return - setGlassRendererState(state, 'ready') + updateRendererState('ready') scheduleFrame() return } @@ -631,18 +1966,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) texture.generateMipmaps = false texture.minFilter = three.LinearFilter texture.magFilter = three.LinearFilter - activeTexture?.dispose() - activeTexture = texture - activeTextureHeight = textureHeight - activeTextureWidth = textureWidth - resources.uniforms.uTexture.value = texture - resources.uniforms.uHasWallpaperTexture.value = 1 - - syncCoverScale() + activateLoadedTexture(texture, textureWidth, textureHeight, true) await resources.renderer.compileAsync(resources.scene, resources.camera) if (version !== loadVersion || !resources) return - setGlassRendererState(state, 'ready') + updateRendererState('ready') scheduleFrame() } @@ -651,11 +1979,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (!toValue(options.active) || !options.canvas.value) return if (!window.WebGLRenderingContext || reducedTransparencyQuery.matches || !toValue(options.wallpaperUrl)) { - setGlassRendererState(state, 'fallback') + updateRendererState('fallback') return } - setGlassRendererState(state, 'loading') + updateRendererState('loading') const version = ++loadVersion try { @@ -667,7 +1995,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) alpha: true, antialias: false, canvas: options.canvas.value, - powerPreference: 'low-power', + powerPreference: 'high-performance', premultipliedAlpha: true, }) renderer.setClearColor(0x000000, 0) @@ -678,16 +2006,36 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const uniforms: GlassRendererUniforms = { uAppearance: { value: getGlassAppearanceUniformValue(toValue(options.appearance)) }, uCoverScale: { value: new three.Vector2(1, 1) }, + uDeformationStrength: { value: getDeformationStrengthScale() }, + uFlowTexture: { value: null }, + uFlowStrength: { value: getFlowStrengthScale() }, + uHasFlowTexture: { value: 0 }, uHasWallpaperTexture: { value: 0 }, + uMotion: { value: 0 }, + uMotionExpansion: { value: getMotionExpansion() }, + uMaxRefractionPixels: { value: getMaxRefractionPixels() }, uPointer: { value: new three.Vector2(0.5, 0.5) }, + uPointerVelocity: { value: new three.Vector2(0, 0) }, + uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) }, + uPreviousCoverScale: { value: new three.Vector2(1, 1) }, uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 }, - uRadii: { value: Array.from({ length: 8 }, () => 0) }, + uReflectionStrength: { value: getReflectionStrengthScale() }, + uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, uRectCount: { value: 0 }, uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, + uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) }, + uPreviousTexture: { value: null }, uTexture: { value: null }, + uTextureMix: { value: 1 }, uTintColor: { value: new three.Color(toValue(options.tintColor)) }, - uTime: { value: 0 }, - uViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) }, + uTransparency: { value: getTransparency() }, + uTranslationStrength: { value: getTranslationStrengthScale() }, + uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) }, + uTrailCount: { value: getRenderProfile().trailCount }, + uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) }, + uScrollOffset: { value: new three.Vector2(0, 0) }, + uWakeDirection: { value: new three.Vector2(0, -1) }, + uWakeProgress: { value: 1 }, } const material = new three.ShaderMaterial({ depthTest: false, @@ -703,6 +2051,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) mesh.frustumCulled = false scene.add(mesh) resources = { camera, geometry, material, mesh, renderer, scene, uniforms } + syncFlowResources() setupObservers() setupEvents() @@ -719,7 +2068,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (event.matches) { disposeRenderer() - setGlassRendererState(state, 'fallback') + updateRendererState('fallback') return } @@ -728,6 +2077,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const reducedTransparencyQuery = matchMedia('(prefers-reduced-transparency: reduce)') reducedTransparencyQuery.addEventListener('change', handleReducedTransparencyChange) + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('blur', handleWindowBlur) + window.addEventListener('focus', handleWindowResume) + window.addEventListener('pageshow', handleWindowResume) watch( () => [toValue(options.active), toValue(options.wallpaperUrl)] as const, @@ -744,14 +2097,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } if (wallpaperUrl !== previous?.[1]) { - const version = ++loadVersion - setGlassRendererState(state, 'loading') - - try { - await loadWallpaper(wallpaperUrl, version) - } catch (error) { - fallbackFromCurrentLoad(version, '玻璃光学壁纸纹理加载失败,已回退标准材质:', error) - } + await refreshWallpaper('玻璃光学壁纸纹理加载失败,已保留当前材质:') } }, { immediate: true }, @@ -769,19 +2115,29 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) watch( () => toValue(options.quality), - async quality => { + async (quality, previousQuality) => { if (!resources) return + const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey)) + const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey)) + resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels( + nextProfile.maxRefractionPixels, + toValue(options.deformationStrength ?? getLegacyDynamicStrength()), + ) resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0 + resources.uniforms.uTrailCount.value = nextProfile.trailCount + interactionAnimating = false + cancelScheduledFrame() + resetInteractionState() + syncFlowResources() resizeRenderer() - const version = ++loadVersion - setGlassRendererState(state, 'loading') - try { - await loadWallpaper(toValue(options.wallpaperUrl), version) - } catch (error) { - fallbackFromCurrentLoad(version, '玻璃质量纹理切换失败,已回退标准材质:', error) + if (!profileRequiresTextureReload(previousProfile, nextProfile)) { + scheduleFrame() + return } + + await refreshWallpaper('玻璃光学质量切换失败,已保留当前材质:') }, ) @@ -795,6 +2151,38 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) }, ) + watch( + () => + [ + toValue(options.translationStrength ?? getLegacyDynamicStrength()), + toValue(options.deformationStrength ?? getLegacyDynamicStrength()), + toValue(options.flowStrength ?? getLegacyDynamicStrength()), + toValue(options.reflectionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT), + toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT), + ] as const, + ([translationStrength, deformationStrength, flowStrength, reflectionStrength, transparencyStrength]) => { + if (!resources) return + + resources.uniforms.uTranslationStrength.value = getGlassOpticalTranslationStrengthScale(translationStrength) + resources.uniforms.uDeformationStrength.value = getGlassOpticalDeformationStrengthScale(deformationStrength) + resources.uniforms.uFlowStrength.value = getGlassOpticalFlowStrengthScale(flowStrength) + resources.uniforms.uMotionExpansion.value = getGlassOpticalMotionExpansion(flowStrength) + resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels( + getRenderProfile().maxRefractionPixels, + deformationStrength, + ) + resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength) + resources.uniforms.uTransparency.value = getGlassOpticalTransparency(transparencyStrength) + if (resources.uniforms.uFlowStrength.value <= 0 && interactionAnimating) { + interactionAnimating = false + cancelScheduledFrame() + pendingFlowInjection = 0 + for (const trail of resources.uniforms.uTrail.value) trail.z = 0 + } + scheduleFrame() + }, + ) + watch( () => toValue(options.routeKey), async (routeKey, previousRouteKey) => { @@ -805,14 +2193,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) resizeRenderer() if (profileRequiresTextureReload(previousProfile, nextProfile)) { - const version = ++loadVersion - setGlassRendererState(state, 'loading') - - try { - await loadWallpaper(toValue(options.wallpaperUrl), version) - } catch (error) { - fallbackFromCurrentLoad(version, '玻璃场景纹理切换失败,已回退标准材质:', error) - } + await refreshWallpaper('玻璃场景纹理切换失败,已保留当前材质:') return } } @@ -822,6 +2203,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) onScopeDispose(() => { reducedTransparencyQuery.removeEventListener('change', handleReducedTransparencyChange) + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('blur', handleWindowBlur) + window.removeEventListener('focus', handleWindowResume) + window.removeEventListener('pageshow', handleWindowResume) disposeRenderer() }) diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts index 7cba1345..ecbcb0ff 100644 --- a/src/composables/useThemeCustomizer.ts +++ b/src/composables/useThemeCustomizer.ts @@ -3,6 +3,13 @@ import { useTheme } from 'vuetify' import { checkPrefersColorSchemeIsDark } from '@/@core/utils' import { saveLocalTheme } from '@/@core/utils/theme' import vuetify from '@/plugins/vuetify' +import { + GLASS_OPTICAL_STRENGTH_DEFAULT, + GLASS_OPTICAL_STRENGTH_MAX, + getGlassOpticalPresetParameters, + normalizeGlassOpticalStrength, + type GlassOpticalPreset, +} from '@/utils/glassOptics' import { themeManager } from '@/utils/themeManager' import { syncThemeFavicon } from '@/utils/themePalette' @@ -64,8 +71,20 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple export interface ThemeCustomizerSettings { /** 玻璃主题的材质语义,与渲染质量保持独立。 */ glassAppearance: ThemeCustomizerGlassAppearance + /** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */ + glassDeformationStrength: number + /** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */ + glassFlowStrength: number + /** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */ + glassPreset: GlassOpticalPreset /** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */ glassQuality: ThemeCustomizerGlassQuality + /** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */ + glassReflectionStrength: number + /** 共享壁纸在表面内的统一采样平移强度,范围 0 到 100。 */ + glassTranslationStrength: number + /** 玻璃材质释放真实壁纸的程度,范围 0 到 100。 */ + glassTransparencyStrength: number /** 桌面导航布局。 */ layout: ThemeCustomizerLayout /** 主题强调色,也是色调玻璃的颜色来源。 */ @@ -86,6 +105,7 @@ type VuetifyThemeApi = ReturnType const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted'] +const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid'] const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high'] const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced' const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal'] @@ -123,9 +143,17 @@ function readStoredThemePreference(): ThemeCustomizerTheme { /** 生成与当前主题偏好一致的定制器默认设置。 */ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings { + const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural') + return { glassAppearance: 'clear', + glassDeformationStrength: glassParameters.deformation, + glassFlowStrength: glassParameters.flow, + glassPreset: 'natural', glassQuality: defaultGlassQuality, + glassReflectionStrength: glassParameters.reflection, + glassTranslationStrength: glassParameters.translation, + glassTransparencyStrength: glassParameters.transparency, layout: 'vertical', primaryColor: defaultPrimaryColor, radius: 'default', @@ -136,6 +164,19 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings { } } +type NormalizableThemeCustomizerSettings = Partial & { + /** 旧版单一动态强度仅用于一次性迁移到三个独立维度。 */ + glassMotionStrength?: unknown +} + +/** 新字段缺失时继承旧版动态强度,避免升级后无声重置用户手感。 */ +function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fallback: number) { + if (typeof value === 'number' && Number.isFinite(value)) return normalizeGlassOpticalStrength(value) + if (typeof legacyValue === 'number' && Number.isFinite(legacyValue)) return normalizeGlassOpticalStrength(legacyValue) + + return fallback +} + /** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow { if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow @@ -145,7 +186,7 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow } /** 规范化持久化的主题定制设置并迁移旧值。 */ -function normalizeThemeCustomizerSettings(settings: Partial): ThemeCustomizerSettings { +function normalizeThemeCustomizerSettings(settings: NormalizableThemeCustomizerSettings): ThemeCustomizerSettings { const fallback = getDefaultThemeCustomizerSettings() const storedRadius = settings.radius as string | undefined const radius = storedRadius === 'huge' ? 'extra' : storedRadius @@ -155,9 +196,29 @@ function normalizeThemeCustomizerSettings(settings: Partial(readThemeCustomizerSettings()) -const glassPreviewState = ref | null>(null) +type ThemeCustomizerGlassSettings = Pick< + ThemeCustomizerSettings, + | 'glassAppearance' + | 'glassDeformationStrength' + | 'glassFlowStrength' + | 'glassPreset' + | 'glassQuality' + | 'glassReflectionStrength' + | 'glassTranslationStrength' + | 'glassTransparencyStrength' +> +const glassPreviewState = ref(null) const effectiveGlassSettings = computed(() => ({ glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance, + glassDeformationStrength: + glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength, + glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength, + glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset, glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality, + glassReflectionStrength: + glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength, + glassTranslationStrength: + glassPreviewState.value?.glassTranslationStrength ?? settingsState.value.glassTranslationStrength, + glassTransparencyStrength: + glassPreviewState.value?.glassTransparencyStrength ?? settingsState.value.glassTransparencyStrength, })) -/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */ +/** 提供当前实际生效的玻璃设置;临时预览优先于已持久化设置。 */ export function useEffectiveGlassSettings() { return readonly(effectiveGlassSettings) } @@ -259,13 +337,29 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme export function applyThemeCustomizerRootSettings( settings: Pick< ThemeCustomizerSettings, - 'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin' + | 'glassAppearance' + | 'glassQuality' + | 'glassReflectionStrength' + | 'glassTransparencyStrength' + | 'layout' + | 'radius' + | 'semiDarkMenu' + | 'shadow' + | 'skin' >, ) { if (!isBrowser()) return document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance) document.documentElement.setAttribute('data-glass-quality', settings.glassQuality) + document.documentElement.style.setProperty( + '--glass-reflection', + String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX), + ) + document.documentElement.style.setProperty( + '--glass-transparency', + String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX), + ) document.documentElement.setAttribute('data-theme-layout', settings.layout) document.documentElement.setAttribute('data-theme-radius', settings.radius) document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) @@ -273,6 +367,14 @@ export function applyThemeCustomizerRootSettings( document.documentElement.setAttribute('data-theme-skin', settings.skin) document.body.setAttribute('data-glass-appearance', settings.glassAppearance) document.body.setAttribute('data-glass-quality', settings.glassQuality) + document.body.style.setProperty( + '--glass-reflection', + String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX), + ) + document.body.style.setProperty( + '--glass-transparency', + String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX), + ) document.body.setAttribute('data-theme-layout', settings.layout) document.body.setAttribute('data-theme-radius', settings.radius) document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu)) @@ -340,10 +442,8 @@ export function persistPartialThemeCustomizerSettings(patch: Partial>, -) { +/** 临时应用玻璃设置,不写入存储或广播持久化变更。 */ +export function previewGlassSettings(patch: Partial) { const previewSettings = normalizeThemeCustomizerSettings({ ...settingsState.value, ...glassPreviewState.value, @@ -352,7 +452,13 @@ export function previewGlassSettings( glassPreviewState.value = { glassAppearance: previewSettings.glassAppearance, + glassDeformationStrength: previewSettings.glassDeformationStrength, + glassFlowStrength: previewSettings.glassFlowStrength, + glassPreset: previewSettings.glassPreset, glassQuality: previewSettings.glassQuality, + glassReflectionStrength: previewSettings.glassReflectionStrength, + glassTranslationStrength: previewSettings.glassTranslationStrength, + glassTransparencyStrength: previewSettings.glassTransparencyStrength, } applyThemeCustomizerRootSettings({ ...settingsState.value, @@ -387,7 +493,13 @@ export function cancelGlassPreview() { export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) { const defaults = normalizeThemeCustomizerSettings({ glassAppearance: 'clear', + glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassPreset: 'natural', glassQuality: defaultGlassQuality, + glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, layout: 'vertical', primaryColor: defaultPrimaryColor, radius: 'default', @@ -399,7 +511,13 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin return ( settings.glassAppearance === defaults.glassAppearance && + settings.glassDeformationStrength === defaults.glassDeformationStrength && + settings.glassFlowStrength === defaults.glassFlowStrength && + settings.glassPreset === defaults.glassPreset && settings.glassQuality === defaults.glassQuality && + settings.glassReflectionStrength === defaults.glassReflectionStrength && + settings.glassTranslationStrength === defaults.glassTranslationStrength && + settings.glassTransparencyStrength === defaults.glassTransparencyStrength && settings.layout === defaults.layout && settings.primaryColor === defaults.primaryColor && settings.radius === defaults.radius && @@ -449,11 +567,41 @@ export function useThemeCustomizer() { return updateSettings({ glassAppearance }) } + /** 更新玻璃局部非均匀形变强度。 */ + function setGlassDeformationStrength(glassDeformationStrength: number) { + return updateSettings({ glassDeformationStrength }) + } + + /** 更新玻璃轨迹、尾波与惯性强度。 */ + function setGlassFlowStrength(glassFlowStrength: number) { + return updateSettings({ glassFlowStrength }) + } + + /** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */ + function setGlassPreset(glassPreset: GlassOpticalPreset) { + return updateSettings({ glassPreset }) + } + /** 更新玻璃主题渲染质量档位。 */ function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) { return updateSettings({ glassQuality }) } + /** 更新玻璃表面反射亮度。 */ + function setGlassReflectionStrength(glassReflectionStrength: number) { + return updateSettings({ glassReflectionStrength }) + } + + /** 更新玻璃统一采样平移强度。 */ + function setGlassTranslationStrength(glassTranslationStrength: number) { + return updateSettings({ glassTranslationStrength }) + } + + /** 更新玻璃材质的真实壁纸可见度。 */ + function setGlassTransparencyStrength(glassTransparencyStrength: number) { + return updateSettings({ glassTransparencyStrength }) + } + /** 更新全局圆角档位。 */ function setRadius(radius: ThemeCustomizerRadius) { return updateSettings({ radius }) @@ -488,7 +636,13 @@ export function useThemeCustomizer() { async function resetSettings() { await updateSettings({ glassAppearance: 'clear', + glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassPreset: 'natural', glassQuality: defaultGlassQuality, + glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, + glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT, layout: 'vertical', primaryColor: defaultPrimaryColor, radius: 'default', @@ -525,7 +679,13 @@ export function useThemeCustomizer() { isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)), resetSettings, setGlassAppearance, + setGlassDeformationStrength, + setGlassFlowStrength, + setGlassPreset, setGlassQuality, + setGlassReflectionStrength, + setGlassTranslationStrength, + setGlassTransparencyStrength, setLayout, setPrimaryColor, setRadius, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 69738b9a..ffafd510 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -156,6 +156,22 @@ export default { glassQualityCss: 'Standard', glassQualityBalanced: 'Balanced', glassQualityHigh: 'High', + glassQualityCssHint: 'Static CSS material with the lowest resource use; clarity and reflection remain available.', + glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.', + glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.', + glassPreset: 'Preset', + glassPresetNatural: 'Natural', + glassPresetGlide: 'Glide', + glassPresetLiquid: 'Liquid', + glassTranslationStrength: 'Sample Translation', + glassDeformationStrength: 'Deformation', + glassFlowStrength: 'Flow Strength', + glassReflectionStrength: 'Reflection Brightness', + glassTransparencyStrength: 'Clarity', + glassOpticalStrengthHint: + 'Sample translation, local deformation, and flow memory stay independent; clarity and reflection do not replace motion.', + glassOpticalStrengthUnavailableHint: + 'This environment keeps clarity and static reflection; the three dynamic controls return with desktop live quality.', purple: 'Purple', custom: 'Custom Style', transparency: 'Transparency', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 4b0c2c9c..bafd6e6c 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -154,6 +154,20 @@ export default { glassQualityCss: '标准', glassQualityBalanced: '均衡', glassQualityHigh: '高质量', + glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透与反射,不启用实时流动。', + glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。', + glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。', + glassPreset: '方案', + glassPresetNatural: '自然', + glassPresetGlide: '滑移', + glassPresetLiquid: '液态', + glassTranslationStrength: '采样平移', + glassDeformationStrength: '形变强度', + glassFlowStrength: '流动强度', + glassReflectionStrength: '反射亮度', + glassTransparencyStrength: '通透度', + glassOpticalStrengthHint: '采样平移、局部形变与流动记忆互相独立;通透度和反射不代替动态参数。', + glassOpticalStrengthUnavailableHint: '当前环境保留通透度和静态反射;三个动态参数会在桌面实时质量下恢复。', purple: '幻紫', custom: '附加样式', transparency: '透明度', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index e09f3a7b..b0a4b88f 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -154,6 +154,20 @@ export default { glassQualityCss: '標準', glassQualityBalanced: '均衡', glassQualityHigh: '高品質', + glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透與反射,不啟用即時流動。', + glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。', + glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。', + glassPreset: '方案', + glassPresetNatural: '自然', + glassPresetGlide: '滑移', + glassPresetLiquid: '液態', + glassTranslationStrength: '採樣平移', + glassDeformationStrength: '形變強度', + glassFlowStrength: '流動強度', + glassReflectionStrength: '反射亮度', + glassTransparencyStrength: '通透度', + glassOpticalStrengthHint: '採樣平移、局部形變與流動記憶互相獨立;通透度和反射不代替動態參數。', + glassOpticalStrengthUnavailableHint: '目前環境保留通透度和靜態反射;三個動態參數會在桌面即時品質下恢復。', purple: '幻紫', custom: '附加樣式', transparency: '透明度', diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index 568facf5..8db864d0 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -3,17 +3,17 @@ // 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。 html[data-theme='glass'] { - --glass-surface: rgba(11, 19, 34, 22%); - --glass-surface-soft: rgba(11, 19, 34, 28%); - --glass-surface-raised: rgba(11, 19, 34, 36%); + --glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)); + --glass-surface-soft: rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)); + --glass-surface-raised: rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)); --glass-control: rgba(11, 19, 34, 52%); --glass-control-prominent: rgba(255, 255, 255, 7%); --glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%)); - --glass-border: rgba(255, 255, 255, 10%); - --glass-border-raised: rgba(255, 255, 255, 14%); - --glass-border-hover: rgba(255, 255, 255, 20%); + --glass-border: rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12)); + --glass-border-raised: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)); + --glass-border-hover: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2)); --glass-control-prominent-border: rgba(255, 255, 255, 14%); - --glass-highlight: rgba(255, 255, 255, 14%); + --glass-highlight: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)); --glass-sheen: linear-gradient( 135deg, rgba(255, 255, 255, 9%), @@ -23,6 +23,11 @@ html[data-theme='glass'] { ); --glass-shadow: 0 10px 28px rgba(3, 7, 18, 24%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%); + --glass-dashboard-shadow: inset 0 1px 0 var(--glass-highlight); + --glass-dashboard-shadow-hover: + inset 0 1px 0 rgba(255, 255, 255, calc(0.16 + var(--glass-reflection, 0.5) * 0.24)), + inset -1px 0 0 rgba(2, 6, 16, calc(0.12 + var(--glass-reflection, 0.5) * 0.1)), + inset 0 -1px 0 rgba(2, 6, 16, calc(0.14 + var(--glass-reflection, 0.5) * 0.1)); --glass-shadow-raised: 0 16px 40px rgba(3, 7, 18, 32%), inset 0 1px 0 rgba(255, 255, 255, 24%), inset 0 -1px 0 rgba(2, 6, 16, 18%); --glass-shadow-hover: @@ -104,9 +109,21 @@ html[data-theme='glass'] { // 色调保持与透明材质接近的透光度,只叠加主色语义。 &[data-glass-appearance='tinted'] { - --glass-surface: color-mix(in srgb, rgba(11, 19, 34, 24%) 88%, rgba(var(--v-theme-primary), 28%)); - --glass-surface-soft: color-mix(in srgb, rgba(11, 19, 34, 30%) 89%, rgba(var(--v-theme-primary), 26%)); - --glass-surface-raised: color-mix(in srgb, rgba(11, 19, 34, 38%) 84%, rgba(var(--v-theme-primary), 32%)); + --glass-surface: color-mix( + in srgb, + rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%, + rgba(var(--v-theme-primary), 28%) + ); + --glass-surface-soft: color-mix( + in srgb, + rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%, + rgba(var(--v-theme-primary), 26%) + ); + --glass-surface-raised: color-mix( + in srgb, + rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%, + rgba(var(--v-theme-primary), 32%) + ); --glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%)); --glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%)); --glass-control-prominent-focus: color-mix( @@ -114,15 +131,31 @@ html[data-theme='glass'] { rgba(255, 255, 255, 10%) 76%, rgba(var(--v-theme-primary), 32%) ); - --glass-border: color-mix(in srgb, rgba(255, 255, 255, 10%) 82%, rgba(var(--v-theme-primary), 26%)); - --glass-border-raised: color-mix(in srgb, rgba(255, 255, 255, 14%) 80%, rgba(var(--v-theme-primary), 30%)); - --glass-border-hover: color-mix(in srgb, rgba(255, 255, 255, 20%) 78%, rgba(var(--v-theme-primary), 36%)); + --glass-border: color-mix( + in srgb, + rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12)) 82%, + rgba(var(--v-theme-primary), 26%) + ); + --glass-border-raised: color-mix( + in srgb, + rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 80%, + rgba(var(--v-theme-primary), 30%) + ); + --glass-border-hover: color-mix( + in srgb, + rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2)) 78%, + rgba(var(--v-theme-primary), 36%) + ); --glass-control-prominent-border: color-mix( in srgb, rgba(255, 255, 255, 14%) 78%, rgba(var(--v-theme-primary), 32%) ); - --glass-highlight: color-mix(in srgb, rgba(255, 255, 255, 14%) 86%, rgba(var(--v-theme-primary), 18%)); + --glass-highlight: color-mix( + in srgb, + rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 86%, + rgba(var(--v-theme-primary), 18%) + ); --glass-sheen: linear-gradient( 135deg, rgba(255, 255, 255, 9%), @@ -134,17 +167,17 @@ html[data-theme='glass'] { // 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。 &[data-glass-appearance='frosted'] { - --glass-surface: rgba(255, 255, 255, 8%); - --glass-surface-soft: rgba(255, 255, 255, 7%); - --glass-surface-raised: rgba(255, 255, 255, 12%); + --glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07)); + --glass-surface-soft: rgba(255, 255, 255, calc(0.11 - var(--glass-transparency, 0.5) * 0.06)); + --glass-surface-raised: rgba(255, 255, 255, calc(0.16 - var(--glass-transparency, 0.5) * 0.08)); --glass-control: rgba(255, 255, 255, 9%); --glass-control-prominent: rgba(255, 255, 255, 10%); --glass-control-prominent-focus: rgba(255, 255, 255, 13%); - --glass-border: rgba(255, 255, 255, 15%); - --glass-border-raised: rgba(255, 255, 255, 18%); - --glass-border-hover: rgba(255, 255, 255, 28%); + --glass-border: rgba(255, 255, 255, calc(0.07 + var(--glass-reflection, 0.5) * 0.16)); + --glass-border-raised: rgba(255, 255, 255, calc(0.08 + var(--glass-reflection, 0.5) * 0.2)); + --glass-border-hover: rgba(255, 255, 255, calc(0.14 + var(--glass-reflection, 0.5) * 0.28)); --glass-control-prominent-border: rgba(255, 255, 255, 18%); - --glass-highlight: rgba(255, 255, 255, 22%); + --glass-highlight: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.24)); --glass-sheen: linear-gradient( 135deg, rgba(255, 255, 255, 7%), @@ -255,11 +288,8 @@ html[data-theme='glass'] { background: transparent; } + // 页面容器不承担玻璃材质,避免内容高度结束处与透明 footer 形成整宽明暗边界。 .layout-page-content { - background-color: rgba(255, 255, 255, 2.5%); - } - - &[data-glass-appearance='frosted'] .layout-page-content { background-color: transparent; } @@ -350,6 +380,19 @@ html[data-theme='glass'] { .dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card { -webkit-backdrop-filter: var(--glass-dashboard-backdrop-filter); backdrop-filter: var(--glass-dashboard-backdrop-filter); + box-shadow: var(--glass-dashboard-shadow) !important; + } + + @media (hover: hover) { + // Dashboard 悬停沿左上来光增强顶部高光,并在右下背光面保留轻吸收。 + .dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card:hover, + .dashboard-grid-item-content + > .dashboard-grid-auto-size + > .dashboard-grid-content-measure + > :first-child + > .v-card:hover { + box-shadow: var(--glass-dashboard-shadow-hover) !important; + } } :where(.layout-navbar, .Vue-Toastification__toast) { @@ -532,8 +575,8 @@ html[data-theme='glass'] { } .v-overlay__scrim { - -webkit-backdrop-filter: var(--glass-surface-backdrop-filter); - backdrop-filter: var(--glass-surface-backdrop-filter); + -webkit-backdrop-filter: none; + backdrop-filter: none; background: rgba(3, 7, 18, 48%); } @@ -950,7 +993,6 @@ html[data-theme='glass'] { // 光学档位只在 renderer ready 后接管背景采样,加载与失败状态继续使用标准 CSS 材质。 .glass-optical-layer { - position: fixed; z-index: 0; block-size: 100%; inline-size: 100%; @@ -960,9 +1002,13 @@ html[data-theme='glass'] { transition: opacity 180ms ease-out; } -// 壁纸交叉淡入期间停用折射输出,避免新纹理采样仍在混合的背景。 -.glass-optical-layer--background-transition { - opacity: 0 !important; +.glass-optical-layer--fixed { + position: fixed; +} + +// 文档表面与该呈现层共享滚动坐标,canvas backing buffer 仍由 renderer 的质量预算约束。 +.glass-optical-layer--scroll { + position: absolute; } // 登录卡片的壁纸副本与外层背景共享切换时序,避免卡片内部提前显示下一张图片。 @@ -980,6 +1026,8 @@ html[data-theme='glass']:is( [data-glass-quality='balanced'], [data-glass-quality='high'] )[data-glass-renderer-state='ready'] { + --glass-surface-backdrop-filter: none; + --glass-raised-backdrop-filter: none; --glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter); } @@ -987,8 +1035,8 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is( [data-glass-quality='balanced'], [data-glass-quality='high'] )[data-glass-renderer-state='ready'] { - --glass-navbar-backdrop-filter: blur(18px) saturate(145%); - --glass-overlay-blur: 18px; + --glass-navbar-backdrop-filter: none; + --glass-overlay-blur: 8px; --glass-overlay-saturate: 145%; } @@ -1012,7 +1060,7 @@ html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass } } -// 磨砂材质的散射由 renderer 与有限的局部扩散共同构成。 +// 磨砂材质的散射由 renderer 统一完成,避免 CSS 再次模糊折射结果。 html[data-glass-appearance='frosted']:is( [data-glass-quality='balanced'], [data-glass-quality='high'] @@ -1020,14 +1068,14 @@ html[data-glass-appearance='frosted']:is( body[data-theme='glass'] { .layout-vertical-nav::before, .layout-navbar { - -webkit-backdrop-filter: blur(18px) saturate(145%) !important; - backdrop-filter: blur(18px) saturate(145%) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !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: blur(24px) saturate(150%) !important; - backdrop-filter: blur(24px) saturate(150%) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; } } diff --git a/src/utils/__tests__/backgroundRotation.spec.ts b/src/utils/__tests__/backgroundRotation.spec.ts index afc61b3b..432eddbd 100644 --- a/src/utils/__tests__/backgroundRotation.spec.ts +++ b/src/utils/__tests__/backgroundRotation.spec.ts @@ -1,4 +1,4 @@ -import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation' +import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation' import { describe, expect, it, vi } from 'vitest' function deferred() { @@ -59,3 +59,31 @@ describe('commitPreloadedBackgroundRotation', () => { expect(commit).not.toHaveBeenCalled() }) }) + +describe('preloadBackgroundRotationImages', () => { + it('does not let an unused optical texture block the visible wallpaper', async () => { + const preload = vi.fn(async (url: string) => url === 'display.jpg') + + await expect( + preloadBackgroundRotationImages({ + displayUrl: 'display.jpg', + preload, + }), + ).resolves.toBe(true) + expect(preload).toHaveBeenCalledOnce() + expect(preload).toHaveBeenCalledWith('display.jpg') + }) + + it('requires both textures when the optical renderer consumes the derived wallpaper', async () => { + const preload = vi.fn(async (url: string) => url === 'display.jpg') + + await expect( + preloadBackgroundRotationImages({ + displayUrl: 'display.jpg', + opticalUrl: 'optical.jpg', + preload, + }), + ).resolves.toBe(false) + expect(preload).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/utils/__tests__/glassOptics.spec.ts b/src/utils/__tests__/glassOptics.spec.ts index f3aa10d8..8e2e2b54 100644 --- a/src/utils/__tests__/glassOptics.spec.ts +++ b/src/utils/__tests__/glassOptics.spec.ts @@ -1,20 +1,41 @@ import { canUseGlassWallpaperTexture, + GLASS_OPTICAL_MOTION_MAX_SCALE, + GLASS_OPTICAL_REFLECTION_MAX_SCALE, + getAvailableGlassOpticalPresets, getGlassCoverScale, + getGlassOpticalDecay, getGlassOpticalBufferSize, + getGlassOpticalMaxRefractionPixels, + getGlassOpticalMotionEnergy, + getGlassOpticalMotionExpansion, + getGlassOpticalMotionStrengthScale, + getGlassOpticalPresetParameters, + getGlassOpticalReflectionStrengthScale, getGlassOpticalRenderProfile, + getGlassOpticalTransparency, + getGlassOpticalSurfaceTransitionWeights, + getGlassOpticalWakeDirection, + getGlassOpticalWakeSample, + getGlassScrollBufferSize, + getGlassWallpaperTransitionProgress, normalizeGlassOpticalRect, + normalizeGlassOpticalStrength, + reconcileGlassOpticalSurfaceSlots, selectGlassOpticalRects, + stepGlassOpticalSpring, type GlassOpticalRect, } from '@/utils/glassOptics' import { describe, expect, it } from 'vitest' describe('glass optics geometry', () => { it('caps the renderer buffer independently from device pixel ratio', () => { - expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 }) - expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 }) - expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 }) - expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 }) + expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 931, width: 1440 }) + expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 844, width: 390 }) + expect(getGlassOpticalBufferSize(1920, 1080, false, 'high', 2)).toEqual({ height: 1080, width: 1920 }) + expect(getGlassOpticalBufferSize(390, 844, true, 'high', 3)).toEqual({ height: 1266, width: 585 }) + expect(getGlassScrollBufferSize(1440, 4200, 'balanced', 2)).toEqual({ height: 3072, width: 1440 }) + expect(getGlassScrollBufferSize(1440, 4200, 'high', 2)).toEqual({ height: 4096, width: 1920 }) }) it('matches cover cropping on wide and tall images', () => { @@ -22,34 +43,305 @@ describe('glass optics geometry', () => { expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 }) }) + it('maps user strength sliders to accelerated high-range optical response', () => { + expect(normalizeGlassOpticalStrength(Number.NaN)).toBe(50) + expect(normalizeGlassOpticalStrength(-12)).toBe(0) + expect(normalizeGlassOpticalStrength(44.6)).toBe(45) + expect(normalizeGlassOpticalStrength(160)).toBe(100) + expect(getGlassOpticalMotionStrengthScale(0)).toBe(0) + expect(getGlassOpticalMotionStrengthScale(50)).toBe(1) + expect(getGlassOpticalMotionStrengthScale(80)).toBeGreaterThan(2) + expect(getGlassOpticalMotionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_MOTION_MAX_SCALE) + expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan( + getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25), + ) + expect(getGlassOpticalMotionExpansion(50)).toBe(0) + expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4) + expect(getGlassOpticalMotionExpansion(100)).toBe(1) + expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9) + expect(getGlassOpticalMaxRefractionPixels(9, 80)).toBe(9) + expect(getGlassOpticalMaxRefractionPixels(9, 100)).toBe(9) + expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0) + expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1) + expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE) + expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28) + expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6) + expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86) + expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96) + expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan( + getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50), + ) + }) + + it('keeps presets as concrete five-parameter values', () => { + const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural') + const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide') + const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid') + + expect(natural).toEqual({ + deformation: 50, + flow: 50, + reflection: 50, + translation: 50, + transparency: 50, + }) + expect(glide.translation).toBeGreaterThan(glide.deformation) + expect(liquid.deformation).toBeGreaterThan(glide.deformation) + expect(liquid.flow).toBeGreaterThan(glide.flow) + expect(getAvailableGlassOpticalPresets('css')).toEqual(['natural']) + expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid']) + }) + + it('returns preset copies so previews cannot mutate the shared matrix', () => { + const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide') + first.translation = 0 + + expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72) + }) + + it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => { + const samples = [0, 250, 750, 1250, 1500].map(elapsed => getGlassWallpaperTransitionProgress(elapsed, 1500)) + + expect(samples[0]).toBe(0) + expect(samples.at(-1)).toBe(1) + expect(samples[2]).toBeGreaterThan(0.5) + expect(samples.every((sample, index) => index === 0 || sample > samples[index - 1])).toBe(true) + }) + it('keeps the selected optical quality on every route', () => { expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({ bufferQuality: 'high', - textureLimit: 3072, + contentProtection: true, + diffusionSamples: 9, + flowField: true, + flowHalfLife: 130, + maxRefractionPixels: 9, + motionDuration: 540, + motionHalfLife: 125, + pixelRatioCap: 1.5, + pointerImmediateResponse: 0.58, + springDamping: 0.78, + springFrequency: 18, + textureLimit: 4096, textureSource: 'wallpaper', + trailCount: 4, }) expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({ bufferQuality: 'high', - textureLimit: 3072, + contentProtection: true, + diffusionSamples: 9, + flowField: true, + flowHalfLife: 130, + maxRefractionPixels: 9, + motionDuration: 540, + motionHalfLife: 125, + pixelRatioCap: 1.5, + pointerImmediateResponse: 0.58, + springDamping: 0.78, + springFrequency: 18, + textureLimit: 4096, textureSource: 'wallpaper', + trailCount: 4, }) expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({ bufferQuality: 'high', - textureLimit: 3072, + contentProtection: true, + diffusionSamples: 9, + flowField: true, + flowHalfLife: 130, + maxRefractionPixels: 9, + motionDuration: 540, + motionHalfLife: 125, + pixelRatioCap: 1.5, + pointerImmediateResponse: 0.58, + springDamping: 0.78, + springFrequency: 18, + textureLimit: 4096, textureSource: 'wallpaper', + trailCount: 4, }) expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({ bufferQuality: 'balanced', - textureLimit: 2048, + contentProtection: false, + diffusionSamples: 5, + flowField: false, + flowHalfLife: 0, + maxRefractionPixels: 6, + motionDuration: 360, + motionHalfLife: 82, + pixelRatioCap: 1, + pointerImmediateResponse: 0.7, + springDamping: 0.9, + springFrequency: 24, + textureLimit: 3072, textureSource: 'wallpaper', + trailCount: 2, }) expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({ bufferQuality: 'high', - textureLimit: 3072, + contentProtection: true, + diffusionSamples: 9, + flowField: true, + flowHalfLife: 130, + maxRefractionPixels: 9, + motionDuration: 540, + motionHalfLife: 125, + pixelRatioCap: 1.5, + pointerImmediateResponse: 0.58, + springDamping: 0.78, + springFrequency: 18, + textureLimit: 4096, textureSource: 'auto', + trailCount: 4, }) }) + it('spends high-quality cost on visible detail and content protection', () => { + const balanced = getGlassOpticalRenderProfile('balanced', '/dashboard') + const high = getGlassOpticalRenderProfile('high', '/dashboard') + + expect(balanced).toMatchObject({ + contentProtection: false, + diffusionSamples: 5, + flowField: false, + maxRefractionPixels: 6, + trailCount: 2, + }) + expect(high).toMatchObject({ + contentProtection: true, + diffusionSamples: 9, + flowField: true, + maxRefractionPixels: 9, + trailCount: 4, + }) + expect(high.motionDuration).toBeGreaterThan(balanced.motionDuration) + expect(high.pixelRatioCap).toBeGreaterThan(balanced.pixelRatioCap) + }) + + it('uses refresh-rate independent decay and reaches a deterministic static state', () => { + expect(getGlassOpticalDecay(100, 100)).toBeCloseTo(0.5) + expect(getGlassOpticalDecay(100, 50) ** 2).toBeCloseTo(getGlassOpticalDecay(100, 100)) + expect(getGlassOpticalMotionEnergy(0, 420, 90)).toBe(1) + expect(getGlassOpticalMotionEnergy(90, 420, 90)).toBeCloseTo(0.5) + expect(getGlassOpticalMotionEnergy(400, 420, 90)).toBeLessThan(0.005) + expect(getGlassOpticalMotionEnergy(410, 420, 90)).toBeLessThan(getGlassOpticalMotionEnergy(400, 420, 90)) + expect(getGlassOpticalMotionEnergy(420, 420, 90)).toBe(0) + + const samples = Array.from({ length: 29 }, (_, index) => getGlassOpticalMotionEnergy(index * 15, 420, 90)) + expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true) + }) + + it('keeps one crest and one trough in the event-relative liquid wake', () => { + const samples = Array.from({ length: 161 }, (_, index) => getGlassOpticalWakeSample(-4 + index * 0.05)) + const extrema = samples + .slice(1, -1) + .filter( + (sample, index) => + (sample > samples[index] && sample > samples[index + 2]) || + (sample < samples[index] && sample < samples[index + 2]), + ) + + expect(extrema).toHaveLength(2) + expect(Math.min(...samples)).toBeLessThan(0) + expect(Math.max(...samples)).toBeGreaterThan(0) + expect(getGlassOpticalWakeSample(0)).toBe(0) + }) + + it('locks wake direction until a deliberate turn starts a new impulse', () => { + expect( + getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: Math.cos(Math.PI / 8), y: Math.sin(Math.PI / 8) }, 0.04, false), + ).toEqual({ x: 1, y: 0 }) + expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.04, false)).toEqual({ x: 0, y: 1 }) + expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.002, false)).toEqual({ x: 1, y: 0 }) + expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 2 }, 0.04, true)).toEqual({ x: 0, y: 1 }) + }) + + it('uses a frame-rate independent spring with at most one visible overshoot', () => { + const profile = getGlassOpticalRenderProfile('high', '/dashboard') + const simulate = (deltaMs: number) => { + let state = { position: 0, velocity: 0 } + const samples: number[] = [] + + for (let elapsed = 0; elapsed < profile.motionDuration; elapsed += deltaMs) { + state = stepGlassOpticalSpring(state, 1, deltaMs, profile.springFrequency, profile.springDamping) + samples.push(state.position) + } + + return { samples, state } + } + const sixtyHertz = simulate(1000 / 60) + const oneTwentyHertz = simulate(1000 / 120) + const visibleCrossings = sixtyHertz.samples.slice(1).filter((sample, index) => { + const previousOffset = sixtyHertz.samples[index] - 1 + const currentOffset = sample - 1 + + return previousOffset * currentOffset < 0 && Math.max(Math.abs(previousOffset), Math.abs(currentOffset)) > 0.002 + }) + + expect(visibleCrossings.length).toBeLessThanOrEqual(1) + expect(Math.max(...sixtyHertz.samples)).toBeLessThan(1.04) + expect(sixtyHertz.state.position).toBeCloseTo(1, 2) + expect(sixtyHertz.state.position).toBeCloseTo(oneTwentyHertz.state.position, 3) + }) + + it('reconciles capped surface slots without reshuffling stable cards', () => { + const candidates = Array.from({ length: 10 }, (_, index) => ({ + key: `card-${index}`, + rect: { + height: 80, + radii: [12, 12, 12, 12] as [number, number, number, number], + rank: index + 1, + width: 90, + x: index * 100, + y: 20, + }, + })) + const initial = reconcileGlassOpticalSurfaceSlots([], candidates, 8) + const onCardA = reconcileGlassOpticalSurfaceSlots(initial, candidates, 8, 'card-8') + const throughGap = reconcileGlassOpticalSurfaceSlots(onCardA, candidates, 8, 'card-8') + const onCardB = reconcileGlassOpticalSurfaceSlots(throughGap, candidates, 8, 'card-9', 'card-8') + + expect(initial.map(slot => slot.key)).toEqual([ + 'card-0', + 'card-1', + 'card-2', + 'card-3', + 'card-4', + 'card-5', + 'card-6', + 'card-7', + ]) + expect(onCardA.map(slot => slot.key)).toEqual([ + 'card-0', + 'card-1', + 'card-2', + 'card-3', + 'card-4', + 'card-5', + 'card-6', + 'card-8', + ]) + expect(throughGap.map(slot => slot.key)).toEqual(onCardA.map(slot => slot.key)) + expect(onCardB.map(slot => slot.key)).toEqual([ + 'card-0', + 'card-1', + 'card-2', + 'card-3', + 'card-4', + 'card-5', + 'card-8', + 'card-9', + ]) + expect(onCardB.at(-2)?.role).toBe('outgoing') + expect(onCardB.at(-1)?.role).toBe('active') + }) + + it('crossfades active surface weights monotonically', () => { + expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 }) + expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 }) + expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 }) + }) + it('only uploads browser-readable login wallpapers to WebGL', () => { const documentUrl = 'https://moviepilot.example/login' @@ -63,10 +355,10 @@ describe('glass optics geometry', () => { it('keeps high-value outer surfaces and removes nested repeats', () => { const candidates: GlassOpticalRect[] = [ - { height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 }, - { height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 }, - { height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 }, - { height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 }, + { height: 900, radii: [0, 0, 0, 0], rank: 2, width: 260, x: 0, y: 0 }, + { height: 300, radii: [16, 16, 16, 16], rank: 4, width: 500, x: 300, y: 100 }, + { height: 100, radii: [12, 12, 12, 12], rank: 5, width: 200, x: 320, y: 120 }, + { height: 120, radii: [12, 12, 12, 12], rank: 1, width: 400, x: 500, y: 700 }, ] const selected = selectGlassOpticalRects(candidates, 1440, 900, false) @@ -77,20 +369,20 @@ describe('glass optics geometry', () => { it('keeps original geometry while using the viewport intersection for visibility', () => { const selected = selectGlassOpticalRects( - [{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }], + [{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }], 320, 240, false, ) - expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }]) + expect(selected).toEqual([{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }]) }) it('budgets partially visible surfaces by their visible pixels', () => { const selected = selectGlassOpticalRects( [ - { height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 }, - { height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 }, + { height: 1000, radii: [20, 20, 20, 20], rank: 1, width: 1000, x: -950, y: -900 }, + { height: 120, radii: [16, 16, 16, 16], rank: 2, width: 200, x: 80, y: 80 }, ], 320, 240, @@ -102,11 +394,58 @@ describe('glass optics geometry', () => { it('converts DOM top-origin rectangles while preserving pixel radius', () => { expect( - normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500), - ).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] }) + normalizeGlassOpticalRect( + { height: 100, radii: [20, 18, 16, 14], rank: 1, width: 200, x: 100, y: 50 }, + 1000, + 500, + ), + ).toEqual({ radii: [20, 18, 16, 14], rect: [0.1, 0.7, 0.2, 0.2] }) expect( - normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500), - ).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] }) + normalizeGlassOpticalRect( + { height: 100, radii: [80, 70, 60, 40], rank: 1, width: 200, x: -20, y: 50 }, + 1000, + 500, + ), + ).toEqual({ + radii: [80 * (10 / 13), 70 * (10 / 13), 60 * (10 / 13), 40 * (10 / 13)], + rect: [-0.02, 0.7, 0.2, 0.2], + }) + + expect( + normalizeGlassOpticalRect({ height: 100, radii: [80, 30, 0, 0], rank: 1, width: 100, x: 0, y: 0 }, 100, 100) + .radii, + ).toEqual([80 * (10 / 11), 30 * (10 / 11), 0, 0]) + }) + + it('keeps visible surfaces without an area-based hard cutoff', () => { + const selected = selectGlassOpticalRects( + [ + { height: 400, radii: [20, 20, 20, 20], rank: 1, width: 700, x: 0, y: 0 }, + { height: 400, radii: [20, 20, 20, 20], rank: 2, width: 700, x: 0, y: 400 }, + ], + 700, + 800, + false, + ) + + expect(selected).toHaveLength(2) + }) + + it('prioritizes the surface under the current interaction when the count is capped', () => { + const candidates = Array.from({ length: 9 }, (_, index): GlassOpticalRect => ({ + height: 80, + radii: [12, 12, 12, 12], + rank: index + 1, + width: 100, + x: index * 110, + y: 20, + })) + + const selected = selectGlassOpticalRects(candidates, 1000, 200, false, { x: 930, y: 60 }) + + expect(selected).toHaveLength(8) + expect(selected).toContain(candidates[8]) + expect(selected).not.toContain(candidates[7]) }) }) diff --git a/src/utils/backgroundRotation.ts b/src/utils/backgroundRotation.ts index fe0029d3..1e8a5935 100644 --- a/src/utils/backgroundRotation.ts +++ b/src/utils/backgroundRotation.ts @@ -7,6 +7,15 @@ interface PreloadedBackgroundRotationOptions { preload: () => Promise } +interface BackgroundRotationImagePreloadOptions { + /** 外层背景实际显示的壁纸地址。 */ + displayUrl: string + /** 实时光学 renderer 确实会消费时才提供的同源纹理地址。 */ + opticalUrl?: string + /** 执行单张图片预加载并返回可用状态。 */ + preload: (url: string) => Promise +} + /** * 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。 */ @@ -18,3 +27,16 @@ export async function commitPreloadedBackgroundRotation(options: PreloadedBackgr options.commit() return true } + +/** + * 预加载一次轮换真正依赖的壁纸;未启用光学采样时不让派生纹理阻断外层背景切换。 + */ +export async function preloadBackgroundRotationImages(options: BackgroundRotationImagePreloadOptions) { + const urls = + options.opticalUrl && options.opticalUrl !== options.displayUrl + ? [options.displayUrl, options.opticalUrl] + : [options.displayUrl] + const results = await Promise.all(urls.map(url => options.preload(url))) + + return results.every(Boolean) +} diff --git a/src/utils/glassOptics.ts b/src/utils/glassOptics.ts index de7d91f7..2b19135b 100644 --- a/src/utils/glassOptics.ts +++ b/src/utils/glassOptics.ts @@ -1,13 +1,45 @@ export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8 export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5 +export const GLASS_OPTICAL_MOTION_MAX_SCALE = 3.2 +export const GLASS_OPTICAL_DEFORMATION_MAX_SCALE = 1.55 +export const GLASS_OPTICAL_FLOW_MAX_SCALE = 1.45 +export const GLASS_OPTICAL_REFLECTION_MAX_SCALE = 1.8 +export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7 +export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50 +export const GLASS_OPTICAL_STRENGTH_MAX = 100 +export const GLASS_OPTICAL_STRENGTH_MIN = 0 +export type GlassAppearance = 'clear' | 'frosted' | 'tinted' +export type GlassOpticalCapability = 'balanced' | 'css' | 'high' +export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural' export type GlassOpticalQuality = 'balanced' | 'high' +export type GlassCornerRadii = [number, number, number, number] + +export interface GlassOpticalParameters { + /** 局部非均匀折射与内容弯曲强度。 */ + deformation: number + /** 轨迹范围、尾波、惯性与收敛强度。 */ + flow: number + /** 方向高光、迎光棱镜与背光吸收强度。 */ + reflection: number + /** 共享壁纸采样在表面内的统一坐标平移强度。 */ + translation: number + /** 壁纸可见度与材质遮罩强度。 */ + transparency: number +} + +export interface GlassInteractionPoint { + /** 指针或触点相对视口的横坐标。 */ + x: number + /** 指针或触点相对视口的纵坐标。 */ + y: number +} export interface GlassOpticalRect { /** 元素的实际高度,元素可部分位于视口外。 */ height: number - /** 元素圆角的 CSS 像素值。 */ - radius: number + /** 按左上、右上、右下、左下排列的 CSS 圆角像素值。 */ + radii: GlassCornerRadii /** 表面的场景优先级,数值越小越优先。 */ rank: number /** 元素的实际宽度,元素可部分位于视口外。 */ @@ -23,13 +55,240 @@ export interface GlassOpticalBufferSize { width: number } +export interface GlassOpticalPoint { + /** 归一化或视口坐标系中的横向分量。 */ + x: number + /** 归一化或视口坐标系中的纵向分量。 */ + y: number +} + +export interface GlassOpticalSpringState { + /** 当前跟随位置。 */ + position: number + /** 当前每秒位移速度。 */ + velocity: number +} + +export interface GlassOpticalSurfaceCandidate { + /** renderer 生命周期内稳定的表面身份。 */ + key: TKey + /** 表面的当前视口几何。 */ + rect: GlassOpticalRect +} + +export interface GlassOpticalSurfaceSlot extends GlassOpticalSurfaceCandidate { + /** 槽位在本次交互中的职责。 */ + role: 'active' | 'outgoing' | 'stable' +} + export interface GlassOpticalRenderProfile { /** 光学层内部缓冲使用的质量档位。 */ bufferQuality: GlassOpticalQuality + /** 是否使用额外壁纸采样保护人物和文字等高梯度内容。 */ + contentProtection: boolean + /** 磨砂扩散使用的纹理采样数。 */ + diffusionSamples: 5 | 9 + /** 是否使用带时序记忆的液态位移场。 */ + flowField: boolean + /** 时序位移场能量衰减到一半所需的时间。 */ + flowHalfLife: number + /** 动态折射在视口像素空间中的软上限。 */ + maxRefractionPixels: number + /** 输入停止后液态形态收敛到静态所需的时间。 */ + motionDuration: number + /** 主液态反馈能量衰减到一半所需的时间。 */ + motionHalfLife: number + /** 高质量缓冲允许使用的设备像素比上限。 */ + pixelRatioCap: number + /** 新输入立即作用于镜片位置的比例。 */ + pointerImmediateResponse: number + /** 镜片跟随弹簧的阻尼比。 */ + springDamping: number + /** 镜片跟随弹簧的角频率,单位弧度每秒。 */ + springFrequency: number /** 活动壁纸进入 GPU 前的最长边限制。 */ textureLimit: number /** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */ textureSource: 'auto' | 'procedural' | 'wallpaper' + /** 参与液态方向计算的最近输入采样数量。 */ + trailCount: number +} + +/** 将用户滑杆输入收敛到 renderer 支持的整数范围,非法存量值回落到默认视觉。 */ +export function normalizeGlassOpticalStrength(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) return GLASS_OPTICAL_STRENGTH_DEFAULT + + return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value))) +} + +const GLASS_OPTICAL_PRESET_MATRIX: Record< + GlassAppearance, + Record> +> = { + clear: { + css: { + natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 }, + glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 }, + liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 }, + }, + balanced: { + natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 }, + glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 }, + liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 }, + }, + high: { + natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 }, + glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 }, + liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 }, + }, + }, + tinted: { + css: { + natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 }, + glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 }, + liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 }, + }, + balanced: { + natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 }, + glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 }, + liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 }, + }, + high: { + natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 }, + glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 }, + liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 }, + }, + }, + frosted: { + css: { + natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 }, + glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 }, + liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 }, + }, + balanced: { + natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 }, + glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 }, + liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 }, + }, + high: { + natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 }, + glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 }, + liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 }, + }, + }, +} + +/** 返回材质、质量与预置共同确定的五个具体参数,调用方可以安全修改返回值。 */ +export function getGlassOpticalPresetParameters( + appearance: GlassAppearance, + quality: GlassOpticalCapability, + preset: GlassOpticalPreset, +): GlassOpticalParameters { + return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] } +} + +/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */ +export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability): GlassOpticalPreset[] { + return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid'] +} + +/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */ +export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) { + if (duration <= 0 || elapsed >= duration) return 1 + if (elapsed <= 0) return 0 + + const target = elapsed / duration + const sample = (time: number, start: number, end: number) => { + const inverse = 1 - time + + return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time + } + let lower = 0 + let upper = 1 + let parameter = target + + for (let iteration = 0; iteration < 10; iteration += 1) { + parameter = (lower + upper) * 0.5 + if (sample(parameter, 0.25, 0.25) < target) lower = parameter + else upper = parameter + } + + return sample(parameter, 0.1, 1) +} + +/** + * 流动强度使用感知曲线:中点保持既有视觉,高区间同时释放更大的形变幅度与空间范围。 + * 最大值仍受质量档和内容保护共同约束,避免高梯度海报出现无界拉伸。 + */ +export function getGlassOpticalMotionStrengthScale(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT + + return normalizedRatio ** Math.log2(GLASS_OPTICAL_MOTION_MAX_SCALE) +} + +/** 采样平移保持中点等于既有即时位移,并在高区间受控增长。 */ +export function getGlassOpticalTranslationStrengthScale(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT + + return normalizedRatio ** Math.log2(GLASS_OPTICAL_TRANSLATION_MAX_SCALE) +} + +/** 非均匀形变独立缩放局部折射,最终像素位移仍受质量档软上限限制。 */ +export function getGlassOpticalDeformationStrengthScale(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT + + return normalizedRatio ** Math.log2(GLASS_OPTICAL_DEFORMATION_MAX_SCALE) +} + +/** 流动维度只延展轨迹、尾波与惯性,中点保持当前时序手感。 */ +export function getGlassOpticalFlowStrengthScale(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT + + return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE) +} + +/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */ +export function getGlassOpticalMotionExpansion(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const highRangeProgress = Math.max( + 0, + (normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT), + ) + + return highRangeProgress ** 1.55 +} + +/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */ +export function getGlassOpticalMaxRefractionPixels(basePixels: number, _value: unknown) { + void _value + + return basePixels +} + +/** 反射强度使用独立感知曲线,只控制光学亮度,不放大背景位移。 */ +export function getGlassOpticalReflectionStrengthScale(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT + + return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE) +} + +/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */ +export function getGlassOpticalTransparency(value: unknown) { + const normalized = normalizeGlassOpticalStrength(value) + if (normalized <= 80) { + const progress = normalized / 80 + + return 0.28 + 0.58 * progress ** 1.25 + } + + const highRangeProgress = (normalized - 80) / 20 + + return 0.86 + 0.1 * highRangeProgress ** 1.5 } /** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */ @@ -37,13 +296,174 @@ export function getGlassOpticalRenderProfile( quality: GlassOpticalQuality, routeKey: string, ): GlassOpticalRenderProfile { + const highQuality = quality === 'high' + return { bufferQuality: quality, - textureLimit: quality === 'high' ? 3072 : 2048, + contentProtection: highQuality, + diffusionSamples: highQuality ? 9 : 5, + flowField: highQuality, + flowHalfLife: highQuality ? 130 : 0, + maxRefractionPixels: highQuality ? 9 : 6, + motionDuration: highQuality ? 540 : 360, + motionHalfLife: highQuality ? 125 : 82, + pixelRatioCap: highQuality ? 1.5 : 1, + pointerImmediateResponse: highQuality ? 0.58 : 0.7, + springDamping: highQuality ? 0.78 : 0.9, + springFrequency: highQuality ? 18 : 24, + textureLimit: highQuality ? 4096 : 3072, textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper', + trailCount: highQuality ? 4 : 2, } } +/** 将时间常数转换为与刷新率无关的指数衰减。 */ +export function getGlassOpticalDecay(halfLife: number, delta: number) { + if (halfLife <= 0) return 0 + if (delta <= 0) return 1 + + return 2 ** (-delta / halfLife) +} + +/** 输入停止后按时间收敛,尾段平滑归零以恢复事件驱动静止状态。 */ +export function getGlassOpticalMotionEnergy(elapsed: number, duration: number, halfLife: number) { + if (elapsed >= duration) return 0 + + const safeElapsed = Math.max(0, elapsed) + const tailDuration = Math.max(1, duration * 0.25) + const tailProgress = Math.min(1, Math.max(0, (duration - safeElapsed) / tailDuration)) + const tailTaper = tailProgress * tailProgress * (3 - 2 * tailProgress) + + return getGlassOpticalDecay(halfLife, safeElapsed) * tailTaper +} + +/** + * 解析阻尼弹簧的精确时间步,避免刷新率变化改变跟随手感。 + * 欠阻尼参数只允许一次可见回摆,生命周期结束后由 renderer 归零。 + */ +export function stepGlassOpticalSpring( + state: GlassOpticalSpringState, + target: number, + deltaMs: number, + frequency: number, + damping: number, +): GlassOpticalSpringState { + const deltaSeconds = Math.min(0.064, Math.max(0, deltaMs / 1000)) + const safeFrequency = Math.max(0.001, frequency) + const safeDamping = Math.max(0, damping) + const offset = state.position - target + if (deltaSeconds <= 0) return state + + if (safeDamping >= 1) { + const decay = Math.exp(-safeFrequency * deltaSeconds) + const coefficient = state.velocity + safeFrequency * offset + + return { + position: target + (offset + coefficient * deltaSeconds) * decay, + velocity: (state.velocity - safeFrequency * coefficient * deltaSeconds) * decay, + } + } + + const dampedFrequency = safeFrequency * Math.sqrt(1 - safeDamping * safeDamping) + const decay = Math.exp(-safeDamping * safeFrequency * deltaSeconds) + const angle = dampedFrequency * deltaSeconds + const cosine = Math.cos(angle) + const sine = Math.sin(angle) + const sineCoefficient = (state.velocity + safeDamping * safeFrequency * offset) / dampedFrequency + const oscillation = offset * cosine + sineCoefficient * sine + + return { + position: target + decay * oscillation, + velocity: + decay * + (-safeDamping * safeFrequency * oscillation - + offset * dampedFrequency * sine + + sineCoefficient * dampedFrequency * cosine), + } +} + +/** 单个双相尾波只有一个波峰和一个波谷,不形成连续周期波列。 */ +export function getGlassOpticalWakeSample(position: number) { + return position * Math.exp(-0.5 * position * position) +} + +/** 低速噪声和小角度移动沿用既有方向,显著转向才开始新的尾波。 */ +export function getGlassOpticalWakeDirection( + current: GlassOpticalPoint, + next: GlassOpticalPoint, + speed: number, + restart: boolean, +): GlassOpticalPoint { + const normalize = (point: GlassOpticalPoint) => { + const length = Math.hypot(point.x, point.y) + + return length > 0.0001 ? { x: point.x / length, y: point.y / length } : { x: 0, y: -1 } + } + const currentDirection = normalize(current) + const nextDirection = normalize(next) + if (restart || Math.hypot(current.x, current.y) <= 0.0001) return nextDirection + if (speed < 0.006) return currentDirection + + const directionDot = currentDirection.x * nextDirection.x + currentDirection.y * nextDirection.y + + return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection +} + +/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */ +export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) { + const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration))) + const eased = progress * progress * (3 - 2 * progress) + + return { + incoming: 0.35 + eased * 0.65, + outgoing: 1 - eased, + } +} + +/** + * 复用仍可见的稳定槽位,并为当前与离场表面保留确定位置。 + * 候选顺序只用于补充空位,指针移动不会重排已经占用的稳定槽位。 + */ +export function reconcileGlassOpticalSurfaceSlots( + previous: GlassOpticalSurfaceSlot[], + candidates: GlassOpticalSurfaceCandidate[], + maxCount: number, + activeKey?: TKey, + outgoingKey?: TKey, +): GlassOpticalSurfaceSlot[] { + if (maxCount <= 0) return [] + + const candidateByKey = new Map(candidates.map(candidate => [candidate.key, candidate])) + const reserved: GlassOpticalSurfaceSlot[] = [] + if (outgoingKey !== undefined && outgoingKey !== activeKey) { + const outgoing = candidateByKey.get(outgoingKey) + if (outgoing) reserved.push({ ...outgoing, role: 'outgoing' }) + } + if (activeKey !== undefined) { + const active = candidateByKey.get(activeKey) + if (active) reserved.push({ ...active, role: 'active' }) + } + + const reservedKeys = new Set(reserved.map(slot => slot.key)) + const stableCount = Math.max(0, maxCount - reserved.length) + const stable: GlassOpticalSurfaceSlot[] = [] + const stableKeys = new Set() + const appendStable = (key: TKey) => { + if (stable.length >= stableCount || reservedKeys.has(key) || stableKeys.has(key)) return + + const candidate = candidateByKey.get(key) + if (!candidate) return + + stable.push({ ...candidate, role: 'stable' }) + stableKeys.add(key) + } + + for (const slot of previous) appendStable(slot.key) + for (const candidate of candidates) appendStable(candidate.key) + + return [...stable, ...reserved].slice(0, maxCount) +} + /** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean { if (!url || !documentUrl) return false @@ -58,18 +478,20 @@ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): b } } -/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */ +/** 按质量档位的像素预算计算内部缓冲,高质量只使用受控的设备像素比增益。 */ export function getGlassOpticalBufferSize( viewportWidth: number, viewportHeight: number, mobile: boolean, quality: GlassOpticalQuality = 'balanced', + devicePixelRatio = 1, ): GlassOpticalBufferSize { - const safeWidth = Math.max(1, viewportWidth) - const safeHeight = Math.max(1, viewportHeight) const highQuality = quality === 'high' - const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960 - const maxHeight = highQuality ? 960 : 720 + const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1 + const safeWidth = Math.max(1, viewportWidth) * pixelRatio + const safeHeight = Math.max(1, viewportHeight) * pixelRatio + const maxWidth = mobile ? (highQuality ? 1280 : 960) : highQuality ? 1920 : 1440 + const maxHeight = mobile ? (highQuality ? 1440 : 960) : highQuality ? 1200 : 960 const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight) return { @@ -78,6 +500,24 @@ export function getGlassOpticalBufferSize( } } +/** 文档空间画布独立限制横纵分辨率,避免长页面按纵横比连带降低横向清晰度。 */ +export function getGlassScrollBufferSize( + presentationWidth: number, + presentationHeight: number, + quality: GlassOpticalQuality, + devicePixelRatio = 1, +): GlassOpticalBufferSize { + const highQuality = quality === 'high' + const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1 + const maxWidth = highQuality ? 1920 : 1440 + const maxHeight = highQuality ? 4096 : 3072 + + return { + height: Math.max(1, Math.round(Math.min(presentationHeight * pixelRatio, maxHeight))), + width: Math.max(1, Math.round(Math.min(presentationWidth * pixelRatio, maxWidth))), + } +} + /** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */ export function getGlassCoverScale( viewportWidth: number, @@ -99,10 +539,9 @@ export function selectGlassOpticalRects( viewportWidth: number, viewportHeight: number, mobile: boolean, + interactionPoint?: GlassInteractionPoint, ): GlassOpticalRect[] { - const viewportArea = Math.max(1, viewportWidth * viewportHeight) const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP - const maxArea = viewportArea * (mobile ? 0.68 : 0.82) const visible = candidates .map(rect => { const left = Math.max(0, rect.x) @@ -120,10 +559,26 @@ export function selectGlassOpticalRects( } }) .filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24) - .sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea) + .sort((left, right) => { + if (interactionPoint) { + const leftContainsInteraction = + interactionPoint.x >= left.rect.x && + interactionPoint.x <= left.rect.x + left.rect.width && + interactionPoint.y >= left.rect.y && + interactionPoint.y <= left.rect.y + left.rect.height + const rightContainsInteraction = + interactionPoint.x >= right.rect.x && + interactionPoint.x <= right.rect.x + right.rect.width && + interactionPoint.y >= right.rect.y && + interactionPoint.y <= right.rect.y + right.rect.height + + if (leftContainsInteraction !== rightContainsInteraction) return leftContainsInteraction ? -1 : 1 + } + + return left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea + }) const selected: typeof visible = [] - let selectedArea = 0 for (const candidate of visible) { if (selected.length >= maxCount) break @@ -138,10 +593,7 @@ export function selectGlassOpticalRects( ) if (nested) continue - if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue - selected.push(candidate) - selectedArea += candidate.visibleArea } return selected.map(candidate => candidate.rect) @@ -151,9 +603,20 @@ export function selectGlassOpticalRects( export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) { const safeWidth = Math.max(1, viewportWidth) const safeHeight = Math.max(1, viewportHeight) + const radii = rect.radii.map(radius => Math.max(0, radius)) as GlassCornerRadii + const [topLeft, topRight, bottomRight, bottomLeft] = radii + const fitScale = (side: number, radiusSum: number) => (radiusSum > 0 ? Math.max(0, side) / radiusSum : 1) + // CSS 会用同一个比例缩小全部圆角,保证任一边的相邻半径之和不超过该边长度。 + const radiusScale = Math.min( + 1, + fitScale(rect.width, topLeft + topRight), + fitScale(rect.width, bottomLeft + bottomRight), + fitScale(rect.height, topLeft + bottomLeft), + fitScale(rect.height, topRight + bottomRight), + ) return { - radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)), + radii: radii.map(radius => radius * radiusScale) as GlassCornerRadii, rect: [ rect.x / safeWidth, 1 - (rect.y + rect.height) / safeHeight,