From a545f88b3c51f6a5975db01bd20e82b07e80fce2 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:34:39 +0800 Subject: [PATCH] feat(glass): add selectable dynamics modes (#639) --- src/App.vue | 1 + src/components/dialog/GlassSettingsDialog.vue | 65 +- .../__tests__/GlassSettingsDialog.spec.ts | 69 ++ src/components/theme/GlassOpticalLayer.vue | 68 +- src/components/theme/ThemeCustomizer.vue | 1 + .../theme/__tests__/GlassOpticalLayer.spec.ts | 160 +++- .../__tests__/useGlassOpticalRenderer.spec.ts | 734 ++++++++++++++- .../__tests__/useThemeCustomizer.spec.ts | 71 +- src/composables/useGlassOpticalRenderer.ts | 848 ++++++++++-------- src/composables/useThemeCustomizer.ts | 20 + src/locales/en-US.ts | 10 + src/locales/zh-CN.ts | 9 + src/locales/zh-TW.ts | 9 + .../__tests__/glassFluidDynamics.spec.ts | 187 ++++ .../__tests__/glassRippleDynamics.spec.ts | 385 ++++++++ src/rendering/glass/glassFluidDynamics.ts | 335 +++++++ src/rendering/glass/glassRippleDynamics.ts | 480 ++++++++++ 17 files changed, 3073 insertions(+), 379 deletions(-) create mode 100644 src/rendering/glass/__tests__/glassFluidDynamics.spec.ts create mode 100644 src/rendering/glass/__tests__/glassRippleDynamics.spec.ts create mode 100644 src/rendering/glass/glassFluidDynamics.ts create mode 100644 src/rendering/glass/glassRippleDynamics.ts diff --git a/src/App.vue b/src/App.vue index 0d4e4a17..883cd7b0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1150,6 +1150,7 @@ onUnmounted(() => { v-if="shouldRenderGlassOpticalLayer" :appearance="effectiveGlassSettings.glassAppearance" :deformation-strength="opticalDeformationStrength" + :dynamics-mode="effectiveGlassSettings.glassDynamicsMode" :flow-strength="opticalFlowStrength" :quality="opticalQuality === 'high' ? 'high' : 'balanced'" :reflection-strength="opticalReflectionStrength" diff --git a/src/components/dialog/GlassSettingsDialog.vue b/src/components/dialog/GlassSettingsDialog.vue index df1b3026..6b1d903e 100644 --- a/src/components/dialog/GlassSettingsDialog.vue +++ b/src/components/dialog/GlassSettingsDialog.vue @@ -5,6 +5,7 @@ import { previewGlassSettings, useThemeCustomizer, type ThemeCustomizerGlassAppearance, + type ThemeCustomizerGlassDynamicsMode, type ThemeCustomizerGlassQuality, } from '@/composables/useThemeCustomizer' import { @@ -43,6 +44,7 @@ const usesMobilePresentation = useGlassMobilePresentation() const { settings } = useThemeCustomizer() const draftAppearance = ref(settings.value.glassAppearance) const draftDeformationStrength = ref(settings.value.glassDeformationStrength) +const draftDynamicsMode = ref(settings.value.glassDynamicsMode) const draftFlowStrength = ref(settings.value.glassFlowStrength) const draftPreset = ref(settings.value.glassPreset) const draftPresetOverrides = ref({ ...settings.value.glassPresetOverrides }) @@ -53,7 +55,8 @@ 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 && !usesMobilePresentation.value) +const showsDynamicsMode = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value) +const showsDynamicTuning = computed(() => showsDynamicsMode.value && draftDynamicsMode.value !== 'off') const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value)) const activePreset = computed(() => availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural', @@ -75,6 +78,7 @@ watch( if (value) { draftAppearance.value = settings.value.glassAppearance draftDeformationStrength.value = settings.value.glassDeformationStrength + draftDynamicsMode.value = settings.value.glassDynamicsMode draftFlowStrength.value = settings.value.glassFlowStrength draftPreset.value = settings.value.glassPreset draftPresetOverrides.value = { ...settings.value.glassPresetOverrides } @@ -120,6 +124,18 @@ const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [ const visiblePresetOptions = computed(() => presetOptions.filter(option => availablePresets.value.includes(option.value)), ) +const dynamicsModeOptions: Array<{ + hint: string + label: string + value: ThemeCustomizerGlassDynamicsMode +}> = [ + { hint: 'theme.glassDynamicsModeFluidHint', label: 'theme.glassDynamicsModeFluid', value: 'fluid' }, + { hint: 'theme.glassDynamicsModeRippleHint', label: 'theme.glassDynamicsModeRipple', value: 'ripple' }, + { hint: 'theme.glassDynamicsModeOffHint', label: 'theme.glassDynamicsModeOff', value: 'off' }, +] +const dynamicsModeHint = computed( + () => dynamicsModeOptions.find(option => option.value === draftDynamicsMode.value)?.hint ?? '', +) /** 仅允许已实现的材质进入待保存设置。 */ function updateAppearance(value: unknown) { @@ -138,11 +154,21 @@ function updateQuality(value: unknown) { applyPreset(activePreset.value) } +/** 仅切换动态效果草稿,六个具体参数和预设覆盖保持原值。 */ +function updateDynamicsMode(value: unknown) { + const option = dynamicsModeOptions.find(item => item.value === value) + if (!option) return + + draftDynamicsMode.value = option.value + previewDraftParameters() +} + /** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */ function previewDraftParameters() { previewGlassSettings({ glassAppearance: draftAppearance.value, glassDeformationStrength: draftDeformationStrength.value, + glassDynamicsMode: draftDynamicsMode.value, glassFlowStrength: draftFlowStrength.value, glassPreset: draftPreset.value, glassPresetOverrides: draftPresetOverrides.value, @@ -258,6 +284,7 @@ async function saveSettings() { previewGlassSettings({ glassAppearance: draftAppearance.value, glassDeformationStrength: draftDeformationStrength.value, + glassDynamicsMode: draftDynamicsMode.value, glassFlowStrength: draftFlowStrength.value, glassPreset: draftPreset.value, glassPresetOverrides: draftPresetOverrides.value, @@ -317,6 +344,7 @@ onScopeDispose(cancelGlassPreview) {{ t(option.label) }} +

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

@@ -362,6 +390,29 @@ onScopeDispose(cancelGlassPreview) {{ t(option.label) }} +

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

+
+ +
+

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

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

{{ t(dynamicsModeHint) }}

@@ -580,6 +631,7 @@ onScopeDispose(cancelGlassPreview) } .glass-settings-dialog__appearance, +.glass-settings-dialog__dynamics-mode, .glass-settings-dialog__quality, .glass-settings-dialog__preset { display: grid; @@ -597,6 +649,11 @@ onScopeDispose(cancelGlassPreview) grid-template-columns: repeat(3, minmax(0, 1fr)); } +.glass-settings-dialog__dynamics-mode { + block-size: 42px !important; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .glass-settings-dialog__quality { block-size: 42px !important; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -615,6 +672,7 @@ onScopeDispose(cancelGlassPreview) } .glass-settings-dialog__appearance-option, +.glass-settings-dialog__dynamics-mode-option, .glass-settings-dialog__quality-option, .glass-settings-dialog__preset-option { border: 0 !important; @@ -630,11 +688,16 @@ onScopeDispose(cancelGlassPreview) block-size: 32px !important; } +.glass-settings-dialog__dynamics-mode-option { + 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__dynamics-mode-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; diff --git a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts index 97f3a454..3e92d6db 100644 --- a/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts +++ b/src/components/dialog/__tests__/GlassSettingsDialog.spec.ts @@ -8,6 +8,8 @@ const dialogStub = { template: '
', } const toggleStub = { + emits: ['update:modelValue'], + name: 'VBtnToggle', props: ['modelValue'], template: '
', } @@ -31,6 +33,7 @@ const mocks = vi.hoisted(() => ({ value: { glassAppearance: 'clear', glassDeformationStrength: 50, + glassDynamicsMode: 'fluid', glassFlowStrength: 50, glassPreset: 'natural', glassPresetOverrides: {}, @@ -78,6 +81,7 @@ describe('GlassSettingsDialog', () => { mocks.display.smAndDown.value = false mocks.settings.value.glassAppearance = 'clear' mocks.settings.value.glassDeformationStrength = 50 + mocks.settings.value.glassDynamicsMode = 'fluid' mocks.settings.value.glassFlowStrength = 50 mocks.settings.value.glassPreset = 'natural' mocks.settings.value.glassPresetOverrides = {} @@ -174,6 +178,7 @@ describe('GlassSettingsDialog', () => { expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassAppearance: 'frosted', glassDeformationStrength: 79, + glassDynamicsMode: 'fluid', glassFlowStrength: 77, glassPreset: 'liquid', glassPresetOverrides: { @@ -218,6 +223,7 @@ describe('GlassSettingsDialog', () => { expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassAppearance: 'clear', glassDeformationStrength: 50, + glassDynamicsMode: 'fluid', glassFlowStrength: 50, glassPreset: 'natural', glassPresetOverrides: { @@ -255,6 +261,7 @@ describe('GlassSettingsDialog', () => { expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false) expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false) + expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false) }) it('keeps the selected preset highlighted and records its combination override', async () => { @@ -335,6 +342,7 @@ describe('GlassSettingsDialog', () => { expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({ glassAppearance: 'clear', glassDeformationStrength: 69, + glassDynamicsMode: 'fluid', glassFlowStrength: 62, glassPreset: 'natural', glassPresetOverrides: { @@ -364,6 +372,7 @@ describe('GlassSettingsDialog', () => { VCard: slotStub, VCardActions: slotStub, VCardText: slotStub, + VBtnToggle: toggleStub, VDialog: dialogStub, VDialogCloseBtn: true, VSlider: sliderStub, @@ -374,6 +383,7 @@ describe('GlassSettingsDialog', () => { const sliders = wrapper.findAll('.slider-stub') expect(sliders).toHaveLength(6) + expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('fluid') expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([ 'theme.glassTransparencyStrength', 'theme.glassTransmissionStrength', @@ -386,6 +396,7 @@ describe('GlassSettingsDialog', () => { it('restores motion tuning when a mobile presentation returns to desktop', async () => { mocks.usesMobilePresentation!.value = true + mocks.settings.value.glassDynamicsMode = 'ripple' mocks.settings.value.glassQuality = 'high' const wrapper = shallowMount(GlassSettingsDialog, { global: { @@ -393,6 +404,7 @@ describe('GlassSettingsDialog', () => { VCard: slotStub, VCardActions: slotStub, VCardText: slotStub, + VBtnToggle: toggleStub, VDialog: dialogStub, VDialogCloseBtn: true, VSlider: sliderStub, @@ -407,6 +419,7 @@ describe('GlassSettingsDialog', () => { 'theme.glassReflectionStrength', ]) expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false) + expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false) expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint') expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint') expect(wrapper.text()).toContain('theme.glassQualityMobileHint') @@ -416,7 +429,63 @@ describe('GlassSettingsDialog', () => { expect(wrapper.findAll('.slider-stub')).toHaveLength(6) expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(true) + expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('ripple') + expect(wrapper.text()).toContain('theme.glassAppearanceHint') + expect(wrapper.text()).toContain('theme.glassPresetHint') expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint') expect(wrapper.text()).toContain('theme.glassOpticalStrengthHint') }) + + it('keeps optical parameters while switching modes and hides motion tuning only when off', async () => { + mocks.settings.value.glassDynamicsMode = 'ripple' + mocks.settings.value.glassQuality = 'balanced' + mocks.settings.value.glassDeformationStrength = 62 + mocks.settings.value.glassFlowStrength = 58 + mocks.settings.value.glassReflectionStrength = 44 + mocks.settings.value.glassTransmissionStrength = 67 + mocks.settings.value.glassTranslationStrength = 76 + mocks.settings.value.glassTransparencyStrength = 53 + const wrapper = shallowMount(GlassSettingsDialog, { + global: { + stubs: { + VCard: slotStub, + VCardActions: slotStub, + VCardText: slotStub, + VBtn: slotStub, + VBtnToggle: toggleStub, + VDialog: dialogStub, + VDialogCloseBtn: true, + VSlider: sliderStub, + }, + }, + props: { modelValue: true }, + }) + const modeControl = wrapper + .findAllComponents({ name: 'VBtnToggle' }) + .find(component => component.classes().includes('glass-settings-dialog__dynamics-mode')) + if (!modeControl) throw new Error('dynamics mode control was not rendered') + + expect(modeControl.attributes('data-model-value')).toBe('ripple') + expect(wrapper.findAll('.slider-stub')).toHaveLength(6) + + modeControl.vm.$emit('update:modelValue', 'off') + await wrapper.vm.$nextTick() + + expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({ + glassAppearance: 'clear', + glassDeformationStrength: 62, + glassDynamicsMode: 'off', + glassFlowStrength: 58, + glassPreset: 'natural', + glassPresetOverrides: {}, + glassQuality: 'balanced', + glassReflectionStrength: 44, + glassTransmissionStrength: 67, + glassTranslationStrength: 76, + glassTransparencyStrength: 53, + }) + expect(wrapper.findAll('.slider-stub')).toHaveLength(3) + expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false) + expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(true) + }) }) diff --git a/src/components/theme/GlassOpticalLayer.vue b/src/components/theme/GlassOpticalLayer.vue index 2d900fda..8a5b1737 100644 --- a/src/components/theme/GlassOpticalLayer.vue +++ b/src/components/theme/GlassOpticalLayer.vue @@ -1,5 +1,10 @@ diff --git a/src/components/theme/ThemeCustomizer.vue b/src/components/theme/ThemeCustomizer.vue index a302a8c5..7e490e45 100644 --- a/src/components/theme/ThemeCustomizer.vue +++ b/src/components/theme/ThemeCustomizer.vue @@ -143,6 +143,7 @@ const hasAppModeCustomization = computed(() => { settings.value.primaryColor !== defaultPrimaryColor || settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance || settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength || + settings.value.glassDynamicsMode !== defaultAppModeGlassSettings.glassDynamicsMode || settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength || settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality || settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength || diff --git a/src/components/theme/__tests__/GlassOpticalLayer.spec.ts b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts index bfce8f2b..70a4ca17 100644 --- a/src/components/theme/__tests__/GlassOpticalLayer.spec.ts +++ b/src/components/theme/__tests__/GlassOpticalLayer.spec.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue' const rendererCalls = vi.hoisted(() => [] as Array>) +const rendererInitialStates = vi.hoisted(() => [] as string[]) const rendererResults = vi.hoisted( () => [] as Array<{ @@ -19,6 +20,7 @@ const rendererResults = vi.hoisted( preparedWallpaperRevision: { value: number } preparedWallpaperUrl: { value: string } renderedFrames: { value: number } + retryAfterFailure: ReturnType rollbackPreparedWallpaperActivation: ReturnType state: { value: string } }>, @@ -61,7 +63,12 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({ preparedWallpaperRevision: ref(0), preparedWallpaperUrl: ref(''), renderedFrames: ref(0), - state: ref('ready'), + state: ref(rendererInitialStates.shift() ?? 'ready'), + retryAfterFailure: vi.fn(() => { + result.state.value = 'loading' + + return Promise.resolve() + }), canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => { return ( result.state.value === 'ready' && @@ -114,6 +121,7 @@ vi.mock('@/composables/useGlassPresentationCapabilities', async () => { afterEach(() => { mobilePresentationState.current!.value = false + rendererInitialStates.length = 0 vi.unstubAllGlobals() }) @@ -126,6 +134,7 @@ describe('GlassOpticalLayer', () => { props: { appearance: 'clear', deformationStrength: 50, + dynamicsMode: 'fluid', flowStrength: 50, previousWallpaperUrl: '', quality: 'balanced', @@ -146,6 +155,7 @@ describe('GlassOpticalLayer', () => { 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.dynamicsMode as { value: string }).value === 'fluid')).toBe(true) expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true) expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true) expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true) @@ -161,6 +171,37 @@ describe('GlassOpticalLayer', () => { expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback') }) + it('does not latch a composite failure while both contexts are initially loading', () => { + rendererCalls.length = 0 + rendererResults.length = 0 + rendererInitialStates.push('loading', 'loading') + const wrapper = shallowMount(GlassOpticalLayer, { + props: { + appearance: 'clear', + deformationStrength: 50, + dynamicsMode: 'ripple', + flowStrength: 50, + previousWallpaperUrl: '', + quality: 'balanced', + reflectionStrength: 50, + routeKey: '/dashboard', + tintColor: '#8D51F9', + transitionDuration: 1500, + transitionStartedAt: 0, + transmissionStrength: 50, + translationStrength: 50, + transparencyStrength: 50, + wallpaperUrl: '/wallpaper.jpg', + }, + }) + + expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true) + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple') + expect(setRendererState).toHaveBeenCalledWith(expect.any(Object), 'loading') + wrapper.unmount() + }) + it('keeps both material contexts while disabling dynamics on mobile presentations', async () => { rendererCalls.length = 0 rendererResults.length = 0 @@ -169,6 +210,7 @@ describe('GlassOpticalLayer', () => { props: { appearance: 'frosted', deformationStrength: 50, + dynamicsMode: 'ripple', flowStrength: 50, previousWallpaperUrl: '', quality: 'high', @@ -186,11 +228,15 @@ describe('GlassOpticalLayer', () => { expect(wrapper.findAll('canvas')).toHaveLength(2) expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true) + expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple') + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') mobilePresentationState.current!.value = false await nextTick() expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true) wrapper.unmount() }) @@ -210,6 +256,7 @@ describe('GlassOpticalLayer', () => { appearance: 'frosted', activateWallpaperRevision: 0, deformationStrength: 50, + dynamicsMode: 'fluid', flowStrength: 50, pendingWallpaperRevision: 7, pendingWallpaperUrl: '/wallpaper-next.jpg', @@ -267,6 +314,7 @@ describe('GlassOpticalLayer', () => { props: { appearance: 'frosted', deformationStrength: 50, + dynamicsMode: 'fluid', flowStrength: 50, pendingWallpaperRevision: 10, pendingWallpaperUrl: '/wallpaper-next.jpg', @@ -322,6 +370,7 @@ describe('GlassOpticalLayer', () => { appearance: 'frosted', activateWallpaperRevision: 8, deformationStrength: 50, + dynamicsMode: 'ripple', flowStrength: 50, pendingWallpaperRevision: 8, pendingWallpaperUrl: '/wallpaper-next.jpg', @@ -348,13 +397,34 @@ describe('GlassOpticalLayer', () => { scrollRenderer.state.value = 'fallback' await nextTick() + expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true) + expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple') + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') expect(requestFrame).not.toHaveBeenCalled() expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled() expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled() expect(wrapper.emitted('wallpaperActivated')).toBeUndefined() + scrollRenderer.state.value = 'loading' + await nextTick() + expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true) + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') + expect(requestFrame).not.toHaveBeenCalled() + + fixedRenderer.state.value = 'loading' scrollRenderer.state.value = 'ready' await nextTick() + expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') + expect(requestFrame).not.toHaveBeenCalled() + + fixedRenderer.state.value = 'ready' + await nextTick() + expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true) + expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true) + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple') expect(requestFrame).toHaveBeenCalledOnce() ;(activationCallback as FrameRequestCallback | null)?.(640) await nextTick() @@ -365,6 +435,93 @@ describe('GlassOpticalLayer', () => { wrapper.unmount() }) + it('retries both contexts when the requested dynamics mode changes after a composite failure', async () => { + rendererCalls.length = 0 + rendererResults.length = 0 + const wrapper = shallowMount(GlassOpticalLayer, { + props: { + appearance: 'clear', + deformationStrength: 50, + dynamicsMode: 'ripple', + flowStrength: 50, + previousWallpaperUrl: '', + quality: 'balanced', + reflectionStrength: 50, + routeKey: '/dashboard', + tintColor: '#8D51F9', + transitionDuration: 1500, + transitionStartedAt: 0, + transmissionStrength: 50, + translationStrength: 50, + transparencyStrength: 50, + wallpaperUrl: '/wallpaper.jpg', + }, + }) + const [fixedRenderer, scrollRenderer] = rendererResults + scrollRenderer.state.value = 'fallback' + await nextTick() + + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') + + await wrapper.setProps({ dynamicsMode: 'fluid' }) + await nextTick() + + expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce() + expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce() + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid') + expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading') + + fixedRenderer.state.value = 'ready' + await nextTick() + expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading') + + scrollRenderer.state.value = 'ready' + await nextTick() + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid') + expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'ready') + wrapper.unmount() + }) + + it('keeps the composite fallback without retrying in a loop when explicit recovery fails', async () => { + rendererCalls.length = 0 + rendererResults.length = 0 + const wrapper = shallowMount(GlassOpticalLayer, { + props: { + appearance: 'clear', + deformationStrength: 50, + dynamicsMode: 'ripple', + flowStrength: 50, + previousWallpaperUrl: '', + quality: 'balanced', + reflectionStrength: 50, + routeKey: '/dashboard', + tintColor: '#8D51F9', + transitionDuration: 1500, + transitionStartedAt: 0, + transmissionStrength: 50, + translationStrength: 50, + transparencyStrength: 50, + wallpaperUrl: '/wallpaper.jpg', + }, + }) + const [fixedRenderer, scrollRenderer] = rendererResults + scrollRenderer.state.value = 'fallback' + await nextTick() + + await wrapper.setProps({ dynamicsMode: 'fluid' }) + await nextTick() + fixedRenderer.state.value = 'ready' + scrollRenderer.state.value = 'fallback' + await nextTick() + await nextTick() + + expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce() + expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce() + expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off') + expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback') + wrapper.unmount() + }) + it.each([ [ 'returns false', @@ -392,6 +549,7 @@ describe('GlassOpticalLayer', () => { appearance: 'frosted', activateWallpaperRevision: 9, deformationStrength: 50, + dynamicsMode: 'fluid', flowStrength: 50, pendingWallpaperRevision: 9, pendingWallpaperUrl: '/wallpaper-next.jpg', diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 884bde11..eff41311 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -12,7 +12,7 @@ import { import { effectScope, nextTick, ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle' -import type { ShaderMaterial, Vector2, WebGLRenderTarget } from 'three' +import type { Object3D, ShaderMaterial, Vector2, WebGLRenderTarget } from 'three' const wallpaperToneMocks = vi.hoisted(() => ({ load: vi.fn(), @@ -294,6 +294,30 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('attaches shared input only while a dynamic presentation is active', async () => { + const active = ref(false) + const scrollListener = vi.fn() + const scope = effectScope() + scope.run(() => { + const source = useGlassOpticalInteractionSource(active) + source.subscribe('scroll', scrollListener) + }) + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400 })) + expect(scrollListener).not.toHaveBeenCalled() + + active.value = true + await nextTick() + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 420, clientY: 420 })) + expect(scrollListener).toHaveBeenCalledOnce() + + active.value = false + await nextTick() + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 440, clientY: 440 })) + expect(scrollListener).toHaveBeenCalledOnce() + scope.stop() + }) + it('detects a target surface added directly', () => { const surface = document.createElement('section') surface.dataset.glassOpticalSurface = '' @@ -2269,6 +2293,90 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('clears an expired ripple field before the first resumed frame', async () => { + const three = await import('three') + let visibilityState: DocumentVisibilityState = 'visible' + let now = 0 + let interactionListener: ((event: PointerEvent | TouchEvent) => void) | null = null + const callbacks = new Map() + let frameId = 0 + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) + vi.spyOn(performance, 'now').mockImplementation(() => now) + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id)) + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode: ref('ripple'), + interactionSource: { + subscribe: vi.fn((_space, listener) => { + interactionListener = listener + return vi.fn() + }), + }, + quality: ref('balanced'), + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + now = 16 + pass * 16 + scheduledCallbacks.forEach(callback => callback(now)) + } + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms + + ;(interactionListener as ((event: PointerEvent) => void) | null)?.({ + clientX: 200, + clientY: 180, + pointerType: 'mouse', + timeStamp: 100, + type: 'pointermove', + } as PointerEvent) + const [interactionFrame] = callbacks.values() + callbacks.clear() + interactionFrame(116.667) + expect(uniforms.uHasRippleTexture.value).toBe(1) + + visibilityState = 'hidden' + document.dispatchEvent(new Event('visibilitychange')) + expect(callbacks.size).toBe(0) + + now = 1000 + visibilityState = 'visible' + document.dispatchEvent(new Event('visibilitychange')) + await vi.waitFor(() => expect(uniforms.uHasRippleTexture.value).toBe(0)) + expect(uniforms.uRippleTexture.value).toBeNull() + for (let pass = 0; pass < 8 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + now = 1016 + pass * 16 + scheduledCallbacks.forEach(callback => callback(now)) + expect(uniforms.uHasRippleTexture.value).toBe(0) + } + expect(callbacks.size).toBe(0) + scope.stop() + surface.remove() + }) + it('coalesces visibility, focus, and pageshow into one renderer resume', async () => { const three = await import('three') const canvas = document.createElement('canvas') @@ -2556,6 +2664,442 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('keeps off mode static, unsubscribed and reversible without changing material rendering', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off') + const unsubscribe = vi.fn() + const interactionSource = { + subscribe: vi.fn(() => unsubscribe), + } + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + deformationStrength: ref(80), + dynamicsMode, + flowStrength: ref(80), + interactionSource, + quality: ref('high'), + reflectionStrength: ref(80), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + transmissionStrength: ref(80), + translationStrength: ref(80), + transparencyStrength: ref(80), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(interactionSource.subscribe).not.toHaveBeenCalled() + const mainScene = [...render.mock.calls] + .reverse() + .map( + call => + call[0] as unknown as { + children?: Array<{ material?: { uniforms?: Record } }> + }, + ) + .find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode) + const uniforms = mainScene?.children?.[0]?.material?.uniforms + expect(uniforms?.uDynamicsMode.value).toBe(2) + expect(uniforms?.uTranslationStrength.value).toBe(0) + expect(uniforms?.uDeformationStrength.value).toBe(0) + expect(uniforms?.uFlowStrength.value).toBe(0) + expect(uniforms?.uTrailCount.value).toBe(0) + expect(uniforms?.uHasFlowTexture.value).toBe(0) + expect(uniforms?.uHasRippleTexture.value).toBe(0) + expect(uniforms?.uReflectionStrength.value).toBeGreaterThan(0) + + const renderedFrames = renderer?.renderedFrames.value + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 300, clientY: 300 })) + dispatchTouchEvent('touchstart', [{ clientX: 300, clientY: 300, identifier: 7 }]) + dispatchTouchEvent('touchmove', [{ clientX: 320, clientY: 320, identifier: 7 }]) + expect(renderer?.renderedFrames.value).toBe(renderedFrames) + + dynamicsMode.value = 'fluid' + await vi.waitFor(() => expect(interactionSource.subscribe).toHaveBeenCalledOnce()) + dynamicsMode.value = 'off' + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + expect(interactionSource.subscribe).toHaveBeenCalledOnce() + scope.stop() + }) + + it('preserves the baseline first fluid velocity but suppresses the first velocity after a mode reset', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid') + const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode, + 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')) + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })) + expect(Math.hypot(uniforms.uPointerVelocity.value.x, uniforms.uPointerVelocity.value.y)).toBeGreaterThan(0) + + dynamicsMode.value = 'off' + await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(2)) + dynamicsMode.value = 'fluid' + await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(0)) + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 260, clientY: 180 })) + expect(uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 }) + scope.stop() + surface.remove() + }) + + it('allocates only the selected temporal field and releases it on every mode change', async () => { + const three = await import('three') + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode, + 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 baselineCompileCalls = compileAsync.mock.calls.length + const baselineDisposeCalls = disposeTarget.mock.calls.length + expect(baselineCompileCalls).toBeGreaterThan(0) + + dynamicsMode.value = 'off' + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls) + + dynamicsMode.value = 'ripple' + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1)) + expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 4) + + dynamicsMode.value = 'off' + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 6)) + scope.stop() + }) + + it('reinitializes a disposed fallback once under the latest mode when explicitly retried', async () => { + const three = await import('three') + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode, + 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 baselineCompileCalls = compileAsync.mock.calls.length + compileAsync.mockRejectedValueOnce(new Error('ripple unavailable')) + dynamicsMode.value = 'ripple' + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1)) + await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback')) + + dynamicsMode.value = 'fluid' + await nextTick() + expect(renderer?.state.value).toBe('fallback') + + await renderer?.retryAfterFailure() + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 2) + expect(warn).toHaveBeenCalledWith('玻璃动态策略切换失败,已回退标准材质:', expect.any(Error)) + scope.stop() + }) + + it('disposes a late ripple compilation result after a newer off selection wins', async () => { + const three = await import('three') + let finishCompilation: ((result: Object3D) => void) | null = null + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode, + 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 baselineCompileCalls = compileAsync.mock.calls.length + compileAsync.mockImplementationOnce( + () => + new Promise(resolve => { + finishCompilation = resolve + }), + ) + dynamicsMode.value = 'ripple' + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1)) + const baselineDisposeCalls = disposeTarget.mock.calls.length + dynamicsMode.value = 'off' + await nextTick() + ;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D) + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + + const mainScene = [...render.mock.calls] + .reverse() + .map( + call => + call[0] as unknown as { + children?: Array<{ material?: { uniforms?: Record } }> + }, + ) + .find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode) + const uniforms = mainScene?.children?.[0]?.material?.uniforms + expect(uniforms?.uDynamicsMode.value).toBe(2) + expect(uniforms?.uRippleTexture.value).toBeNull() + expect(uniforms?.uHasRippleTexture.value).toBe(0) + scope.stop() + }) + + it('ignores a late ripple compilation failure after a newer off selection wins', async () => { + const three = await import('three') + let rejectCompilation: ((error: Error) => void) | null = null + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode, + 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 baselineCompileCalls = compileAsync.mock.calls.length + compileAsync.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectCompilation = reject + }), + ) + dynamicsMode.value = 'ripple' + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1)) + const baselineDisposeCalls = disposeTarget.mock.calls.length + dynamicsMode.value = 'off' + await nextTick() + ;(rejectCompilation as ((error: Error) => void) | null)?.(new Error('stale ripple compile failed')) + + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + expect(renderer?.state.value).toBe('ready') + scope.stop() + }) + + it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + let finishCompilation: ((result: Object3D) => void) | null = null + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce( + () => + new Promise(resolve => { + finishCompilation = resolve + }), + ) + const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const addWindowListener = vi.spyOn(window, 'addEventListener') + const addCanvasListener = vi.spyOn(canvas, 'addEventListener') + const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) => + calls.filter(([event]) => String(event) === eventName).length + const scope = effectScope() + scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + dynamicsMode: ref('ripple'), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce()) + expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0) + expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0) + expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) + const baselineDisposeCalls = disposeTarget.mock.calls.length + + scope.stop() + ;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D) + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + + expect(ResizeObserverMock.instances).toHaveLength(0) + expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0) + expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0) + expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) + expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1) + }) + + it('recovers once when context loss occurs during initial ripple compilation', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + let finishFirstCompilation: ((result: Object3D) => void) | null = null + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce( + () => + new Promise(resolve => { + finishFirstCompilation = resolve + }), + ) + const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const addWindowListener = vi.spyOn(window, 'addEventListener') + const addCanvasListener = vi.spyOn(canvas, 'addEventListener') + const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) => + calls.filter(([event]) => String(event) === eventName).length + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + dynamicsMode: ref('ripple'), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce()) + expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) + expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0) + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })) + expect(renderer?.state.value).toBe('fallback') + + canvas.dispatchEvent(new Event('webglcontextrestored')) + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(compileAsync).toHaveBeenCalledTimes(3) + const listenerCountsBeforeLateResult = { + contextLost: countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost'), + pointerMove: countListenerAdds(addWindowListener.mock.calls, 'pointermove'), + resize: countListenerAdds(addWindowListener.mock.calls, 'resize'), + } + const baselineDisposeCalls = disposeTarget.mock.calls.length + + ;(finishFirstCompilation as ((result: Object3D) => void) | null)?.({} as Object3D) + await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + + expect(renderer?.state.value).toBe('ready') + expect(ResizeObserverMock.instances).toHaveLength(1) + expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe( + listenerCountsBeforeLateResult.contextLost, + ) + expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe( + listenerCountsBeforeLateResult.pointerMove, + ) + expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(listenerCountsBeforeLateResult.resize) + scope.stop() + }) + + it('restores the latest off mode after an active ripple renderer loses context', async () => { + const three = await import('three') + const canvas = document.createElement('canvas') + const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const unsubscribe = vi.fn() + const subscribe = vi.fn(() => unsubscribe) + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('ripple') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(canvas), + dynamicsMode, + interactionSource: { subscribe }, + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(subscribe).toHaveBeenCalledOnce() + const compileCallsBeforeLoss = compileAsync.mock.calls.length + + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })) + expect(renderer?.state.value).toBe('fallback') + expect(unsubscribe).toHaveBeenCalledOnce() + + dynamicsMode.value = 'off' + await nextTick() + await renderer?.retryAfterFailure() + expect(renderer?.state.value).toBe('fallback') + expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss) + + canvas.dispatchEvent(new Event('webglcontextrestored')) + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + + expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss + 1) + expect(subscribe).toHaveBeenCalledOnce() + const mainScene = [...render.mock.calls] + .reverse() + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms + expect(uniforms.uDynamicsMode.value).toBe(2) + expect(uniforms.uRippleTexture.value).toBeNull() + expect(uniforms.uHasRippleTexture.value).toBe(0) + + const framesBeforePointer = renderer?.renderedFrames.value + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })) + await nextTick() + expect(renderer?.renderedFrames.value).toBe(framesBeforePointer) + 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) @@ -2794,6 +3338,67 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('clears ripple state before native scroll presentation takes ownership', async () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const callbacks = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + frameId += 1 + callbacks.set(frameId, callback) + + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id)) + appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 }) + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode: ref('ripple'), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) { + const scheduledCallbacks = [...callbacks.values()] + callbacks.clear() + scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16)) + } + const mainScene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode) + if (!mainScene) throw new Error('main optical scene was not rendered') + const uniforms = mainScene.children[0].material!.uniforms + expect(uniforms.uDynamicsMode.value).toBe(1) + expect(uniforms.uHasRippleTexture.value).toBe(0) + + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 })) + expect(callbacks.size).toBe(1) + const rippleFrames = [...callbacks.values()] + callbacks.clear() + rippleFrames.forEach(callback => callback(performance.now() + 16)) + expect(uniforms.uHasRippleTexture.value).toBe(1) + expect(callbacks.size).toBe(1) + + window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 })) + + expect(callbacks.size).toBe(0) + expect(uniforms.uHasRippleTexture.value).toBe(0) + expect(uniforms.uRippleTexture.value).toBeNull() + expect(document.documentElement.dataset.glassScrollPresentation).toBe('native') + scope.stop() + }) + it('ignores scroll intent and movement that cannot move a managed glass surface', async () => { vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) @@ -3516,6 +4121,15 @@ describe('glass optical surface discovery', () => { expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength') expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength') expect(scene.children[0].material.fragmentShader).toContain('uniform float uDynamicsOnly') + expect(scene.children[0].material.fragmentShader).toContain( + 'float rippleGradientEnergy = smoothstep(0.003, 0.08, rippleGradientLength)', + ) + expect(scene.children[0].material.fragmentShader).toContain( + 'rippleGradient * mix(230.0, 335.0, uQuality) * uRippleDeformationStrength', + ) + expect(scene.children[0].material.fragmentShader).toContain( + 'uAppearance > 1.5 ? 1.25 : (uAppearance > 0.5 ? 0.86 : 0.72)', + ) expect(scene.children[0].material.fragmentShader).toContain('float sharedWaveDensity = mix(2.81, 1.63') expect(scene.children[0].material.fragmentShader).toContain( 'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)', @@ -3750,4 +4364,122 @@ describe('glass optical surface discovery', () => { expect(callbacks.size).toBe(0) scope.stop() }) + + it('keeps static material rendering while off owns no interaction subscription or dynamic output', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const subscribe = vi.fn(() => vi.fn()) + const deformationStrength = ref(80) + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off') + const reflectionStrength = ref(40) + 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, + dynamicsMode, + flowStrength: ref(80), + interactionSource: { subscribe }, + quality: ref('high'), + reflectionStrength, + routeKey: ref('/dashboard'), + surfaceSpace: 'scroll', + tintColor: ref('#8D51F9'), + translationStrength: ref(80), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const scene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode) + if (!scene) throw new Error('main optical scene was not rendered') + const uniforms = scene.children[0].material!.uniforms + + expect(subscribe).not.toHaveBeenCalled() + expect(uniforms.uDynamicsMode.value).toBe(2) + expect(uniforms.uTranslationStrength.value).toBe(0) + expect(uniforms.uDeformationStrength.value).toBe(0) + expect(uniforms.uFlowStrength.value).toBe(0) + expect(uniforms.uRippleDeformationStrength.value).toBe(0) + expect(uniforms.uTrailCount.value).toBe(0) + expect(uniforms.uHasFlowTexture.value).toBe(0) + expect(uniforms.uHasRippleTexture.value).toBe(0) + expect(uniforms.uReflectionStrength.value).toBeGreaterThan(0) + + const framesBeforePointer = renderer?.renderedFrames.value + window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 })) + await nextTick() + expect(renderer?.renderedFrames.value).toBe(framesBeforePointer) + + deformationStrength.value = 100 + reflectionStrength.value = 100 + await nextTick() + expect(uniforms.uDeformationStrength.value).toBe(0) + expect(uniforms.uRippleDeformationStrength.value).toBe(0) + expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1) + scope.stop() + }) + + it('keeps fluid and ripple resources mutually exclusive across rapid mode switches', async () => { + const three = await import('three') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const subscribe = vi.fn(() => vi.fn()) + const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid') + 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), + dynamicsMode, + flowStrength: ref(70), + interactionSource: { subscribe }, + quality: ref('high'), + 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')) + const scene = render.mock.calls + .map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }) + .find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode) + if (!scene) throw new Error('main optical scene was not rendered') + const uniforms = scene.children[0].material!.uniforms + expect(uniforms.uDynamicsMode.value).toBe(0) + expect(uniforms.uHasFlowTexture.value).toBe(1) + expect(uniforms.uHasRippleTexture.value).toBe(0) + + dynamicsMode.value = 'ripple' + await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(1)) + expect(uniforms.uHasFlowTexture.value).toBe(0) + expect(uniforms.uHasRippleTexture.value).toBe(0) + expect(uniforms.uMaxRefractionPixels.value).toBeCloseTo(28) + expect( + render.mock.calls.some(call => { + const rippleScene = call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> } + return rippleScene.children[0]?.material?.fragmentShader.includes('uImpulseSigma') + }), + ).toBe(true) + + dynamicsMode.value = 'off' + dynamicsMode.value = 'fluid' + await vi.waitFor(() => { + expect(uniforms.uDynamicsMode.value).toBe(0) + expect(uniforms.uHasFlowTexture.value).toBe(1) + }) + expect(uniforms.uHasRippleTexture.value).toBe(0) + expect(subscribe).toHaveBeenCalled() + scope.stop() + }) }) diff --git a/src/composables/__tests__/useThemeCustomizer.spec.ts b/src/composables/__tests__/useThemeCustomizer.spec.ts index 88758ec4..62deac9e 100644 --- a/src/composables/__tests__/useThemeCustomizer.spec.ts +++ b/src/composables/__tests__/useThemeCustomizer.spec.ts @@ -47,6 +47,7 @@ describe('useThemeCustomizer glass settings', () => { expect(settings.glassAppearance).toBe('clear') expect(settings.glassDeformationStrength).toBe(48) + expect(settings.glassDynamicsMode).toBe('ripple') expect(settings.glassFlowStrength).toBe(48) expect(settings.glassPreset).toBe('natural') expect(settings.glassPresetOverrides).toEqual({}) @@ -71,6 +72,14 @@ describe('useThemeCustomizer glass settings', () => { expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true) expect(customizer.isCustomized.value).toBe(false) + await customizer.setGlassDynamicsMode('off') + expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(false) + expect(customizer.isCustomized.value).toBe(true) + + await customizer.resetSettings() + expect(customizer.settings.value.glassDynamicsMode).toBe('ripple') + expect(customizer.isCustomized.value).toBe(false) + wrapper.unmount() }) @@ -78,6 +87,7 @@ describe('useThemeCustomizer glass settings', () => { expect(getDefaultGlassCustomizerSettings('css')).toEqual({ glassAppearance: 'clear', glassDeformationStrength: 48, + glassDynamicsMode: 'ripple', glassFlowStrength: 48, glassPreset: 'natural', glassPresetOverrides: {}, @@ -101,15 +111,27 @@ describe('useThemeCustomizer glass settings', () => { expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance) }) + it.each(['fluid', 'ripple', 'off'] as const)('preserves the %s dynamics mode contract', glassDynamicsMode => { + localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassDynamicsMode })) + + expect(readThemeCustomizerSettings().glassDynamicsMode).toBe(glassDynamicsMode) + }) + it('falls back when stored glass settings are invalid', () => { localStorage.setItem( THEME_CUSTOMIZER_STORAGE_KEY, - JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }), + JSON.stringify({ + glassAppearance: 'opaque', + glassDynamicsMode: 'elastic', + glassPreset: 'elastic', + glassQuality: 'ultra', + }), ) const settings = readThemeCustomizerSettings() expect(settings.glassAppearance).toBe('clear') + expect(settings.glassDynamicsMode).toBe('ripple') expect(settings.glassPreset).toBe('natural') expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural') expect(settings.glassQuality).toBe('balanced') @@ -184,9 +206,7 @@ describe('useThemeCustomizer glass settings', () => { expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42') expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70) expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('') - expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo( - 0.48, - ) + expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48) expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48) expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72) expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65) @@ -195,10 +215,12 @@ describe('useThemeCustomizer glass settings', () => { it('previews glass settings without persisting them', () => { const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY) - previewGlassSettings({ glassAppearance: 'tinted' }) + previewGlassSettings({ glassAppearance: 'tinted', glassDynamicsMode: 'ripple' }) expect(document.documentElement.dataset.glassAppearance).toBe('tinted') expect(readThemeCustomizerSettings().glassAppearance).toBe('clear') + expect(readThemeCustomizerSettings().glassDynamicsMode).toBe('ripple') + expect(useEffectiveGlassSettings().value.glassDynamicsMode).toBe('ripple') expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview) }) @@ -208,6 +230,7 @@ describe('useThemeCustomizer glass settings', () => { previewGlassSettings({ glassAppearance: 'tinted', glassDeformationStrength: 74, + glassDynamicsMode: 'ripple', glassFlowStrength: 63, glassPreset: 'natural', glassPresetOverrides: { @@ -232,6 +255,7 @@ describe('useThemeCustomizer glass settings', () => { expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassDeformationStrength: 74, + glassDynamicsMode: 'ripple', glassFlowStrength: 63, glassPreset: 'natural', glassPresetOverrides: { @@ -288,6 +312,7 @@ describe('useThemeCustomizer glass settings', () => { previewGlassSettings({ glassAppearance: 'clear', glassDeformationStrength: 90, + glassDynamicsMode: 'off', glassFlowStrength: 88, glassPresetOverrides: { 'clear:balanced:natural': { @@ -307,6 +332,7 @@ describe('useThemeCustomizer glass settings', () => { expect(effective.value).toMatchObject({ glassAppearance: 'clear', glassDeformationStrength: 90, + glassDynamicsMode: 'off', glassFlowStrength: 88, glassReflectionStrength: 12, glassTransmissionStrength: 92, @@ -320,6 +346,7 @@ describe('useThemeCustomizer glass settings', () => { expect(effective.value).toMatchObject({ glassAppearance: 'tinted', glassDeformationStrength: 42, + glassDynamicsMode: 'ripple', glassFlowStrength: 44, glassPresetOverrides: { 'tinted:balanced:natural': { @@ -338,6 +365,40 @@ describe('useThemeCustomizer glass settings', () => { }) }) + it('switches dynamics mode without changing preset ownership or optical parameters', async () => { + persistPartialThemeCustomizerSettings({ + glassAppearance: 'tinted', + glassDeformationStrength: 73, + glassFlowStrength: 61, + glassPreset: 'glide', + glassPresetOverrides: { + 'tinted:high:glide': { + deformation: 73, + flow: 61, + reflection: 47, + transmission: 68, + translation: 82, + transparency: 59, + }, + }, + glassQuality: 'high', + glassReflectionStrength: 47, + glassTransmissionStrength: 68, + glassTranslationStrength: 82, + glassTransparencyStrength: 59, + }) + const { customizer, wrapper } = mountThemeCustomizer() + const before = readThemeCustomizerSettings() + + await customizer.setGlassDynamicsMode('off') + expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'off' }) + + await customizer.setGlassDynamicsMode('ripple') + expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'ripple' }) + + wrapper.unmount() + }) + it('applies the same preset for a new material and quality while preset-managed', async () => { persistPartialThemeCustomizerSettings({ glassAppearance: 'clear', diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index 59303676..b9f149bd 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -45,7 +45,21 @@ import { type GlassOpticalSurfaceMode, type GlassOpticalSurfaceSlot, } from '@/utils/glassOptics' -import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer' +import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassDynamicsMode } from '@/composables/useThemeCustomizer' +import { + createGlassRippleDynamics, + type GlassRippleDynamics, + type GlassRippleQuality, +} from '@/rendering/glass/glassRippleDynamics' +import { + createGlassFluidDynamics, + GLASS_FLUID_FRAGMENT_SETUP, + GLASS_FLUID_FRAGMENT_SURFACE_OPTICS, + GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION, + GLASS_FLUID_FRAGMENT_SURFACE_SHAPE, + GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD, + type GlassFluidDynamics, +} from '@/rendering/glass/glassFluidDynamics' import type { PagePresentationMotionReader } from '@/composables/usePagePresentationMotion' import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle' import { @@ -217,21 +231,44 @@ export function useGlassOpticalInteractionSource( } } - 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 }) + let listening = false - onScopeDispose(() => { - listeners.fixed.clear() - listeners.scroll.clear() + function attachGlobalListeners() { + if (listening) return + + listening = true + 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 }) + } + + function detachGlobalListeners() { + if (!listening) return + + listening = false touchOwners.clear() window.removeEventListener('pointermove', dispatch) window.removeEventListener('touchstart', dispatch) window.removeEventListener('touchmove', dispatch) window.removeEventListener('touchend', dispatch) window.removeEventListener('touchcancel', dispatch) + } + + watch( + () => toValue(active), + enabled => { + if (enabled) attachGlobalListeners() + else detachGlobalListeners() + }, + { flush: 'sync', immediate: true }, + ) + + onScopeDispose(() => { + listeners.fixed.clear() + listeners.scroll.clear() + detachGlobalListeners() }) return { @@ -249,6 +286,7 @@ interface GlassRendererUniforms extends Record { uCoverScale: IUniform uDeformationStrength: IUniform uDynamicsOnly: IUniform + uDynamicsMode: IUniform uFlowTexture: IUniform uFlowStrength: IUniform uHasWallpaperTexture: IUniform @@ -267,6 +305,10 @@ interface GlassRendererUniforms extends Record { uPreviousWallpaperExposure: IUniform uQuality: IUniform uReflectionStrength: IUniform + uRippleDeformationStrength: IUniform + uRippleTexelSize: IUniform + uRippleTexture: IUniform + uHasRippleTexture: IUniform uRadii: IUniform uRectCount: IUniform uRects: IUniform @@ -291,16 +333,6 @@ interface GlassRendererUniforms extends Record { uWakeDirection: IUniform } -interface GlassFlowUniforms extends Record { - uDecay: IUniform - uInjection: IUniform - uPointer: IUniform - uPrevious: IUniform - uTexelSize: IUniform - uVelocity: IUniform - uViewportAspect: IUniform -} - interface GlassRendererResources { camera: OrthographicCamera geometry: BufferGeometry @@ -311,15 +343,6 @@ interface GlassRendererResources { uniforms: GlassRendererUniforms } -interface GlassFlowResources { - material: ShaderMaterial - mesh: Mesh - readTarget: WebGLRenderTarget - scene: Scene - uniforms: GlassFlowUniforms - writeTarget: WebGLRenderTarget -} - interface GlassFrostPrefilterResources { material: ShaderMaterial mesh: Mesh @@ -338,6 +361,8 @@ interface UseGlassOpticalRendererOptions { deformationStrength?: MaybeRefOrGetter /** 是否启用交互形变、尾迹和 temporal flow;静态材质不受影响。 */ dynamicsActive?: MaybeRefOrGetter + /** 当前互斥动态策略;运行时降级由 dynamicsActive 统一门控。 */ + dynamicsMode?: MaybeRefOrGetter flowStrength?: MaybeRefOrGetter interactionSource?: GlassOpticalInteractionSource /** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */ @@ -444,48 +469,6 @@ void main() { } ` -const DYNAMIC_RANGE_SCALE = 0.4 -const DYNAMIC_RANGE_DENSITY = 1 / DYNAMIC_RANGE_SCALE ** 2 - -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 * DYNAMIC_RANGE_DENSITY).toFixed(3)}) * 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 * DYNAMIC_RANGE_DENSITY).toFixed(3)}); - - 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 FROST_PREFILTER_FRAGMENT_SHADER = ` precision highp float; @@ -548,10 +531,12 @@ uniform vec2 uScrollOffset; uniform vec2 uVisibleViewportSize; uniform float uDeformationStrength; uniform float uDynamicsOnly; +uniform float uDynamicsMode; uniform float uFlowStrength; uniform float uHasWallpaperTexture; uniform float uHasFlowTexture; uniform float uHasFrostedTexture; +uniform float uHasRippleTexture; uniform vec4 uInteractionRadii[8]; uniform vec4 uInteractionRects[8]; uniform int uInteractionRectCount; @@ -563,6 +548,9 @@ uniform vec2 uPointerVelocity; uniform vec2 uWakeDirection; uniform float uQuality; uniform float uReflectionStrength; +uniform float uRippleDeformationStrength; +uniform vec2 uRippleTexelSize; +uniform sampler2D uRippleTexture; uniform vec4 uRects[8]; uniform vec4 uRadii[8]; uniform float uSurfaceWeights[8]; @@ -609,6 +597,25 @@ vec2 coverUv(vec2 uv) { return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale; } +vec2 rippleFieldUv(vec2 uv) { + vec2 documentPixels = vec2(uv.x, 1.0 - uv.y) * uPresentationSize; + vec2 viewportPixels = documentPixels - uScrollOffset; + + return vec2( + viewportPixels.x / max(uVisibleViewportSize.x, 1.0), + 1.0 - viewportPixels.y / max(uVisibleViewportSize.y, 1.0) + ); +} + +vec3 sampleRippleState(vec2 uv) { + if (uHasRippleTexture < 0.5) return vec3(0.0); + + vec4 encoded = texture2D(uRippleTexture, uv); + if (encoded.b < (1.0 / 255.0)) return vec3(0.0); + + return vec3(encoded.r * 2.0 - 1.0, encoded.g * 2.0 - 1.0, encoded.b); +} + vec3 compressWallpaperLuminance(vec3 color, float wallpaperExposure) { vec3 exposed = max(color * wallpaperExposure, vec3(0.0)); float sourceLuminance = dot(exposed, vec3(0.2126, 0.7152, 0.0722)); @@ -792,14 +799,7 @@ void main() { float dynamicMask = 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; - float trailSpatialSpan = 0.0; - float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion); - const float dynamicRangeScale = ${DYNAMIC_RANGE_SCALE.toFixed(2)}; - const float dynamicRangeDensity = ${DYNAMIC_RANGE_DENSITY.toFixed(3)}; +${GLASS_FLUID_FRAGMENT_SETUP} float interactionMask = uInteractionRectCount > 0 ? 0.0 : 1.0; for (int interactionIndex = 0; interactionIndex < 8; interactionIndex++) { if (interactionIndex >= uInteractionRectCount) break; @@ -816,52 +816,28 @@ void main() { ); } - 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) * motionRangeCompression; - vec2 trailSpanDelta = trail.xy - uPointer; - trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; - trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z); - float along = dot(trailDelta, wakeDirection); - float across = dot(trailDelta, wakePerpendicular); - float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity; - float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion) * dynamicRangeDensity; - 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 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; +${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD} + float rippleMode = step(0.5, uDynamicsMode) * (1.0 - step(1.5, uDynamicsMode)); + vec3 rippleState = vec3(0.0); + vec2 rippleGradient = vec2(0.0); + if (rippleMode > 0.5 && uHasRippleTexture > 0.5) { + vec2 rippleUv = rippleFieldUv(vUv); + rippleState = sampleRippleState(rippleUv); + float rippleLeft = sampleRippleState(rippleUv - vec2(uRippleTexelSize.x, 0.0)).x; + float rippleRight = sampleRippleState(rippleUv + vec2(uRippleTexelSize.x, 0.0)).x; + float rippleBottom = sampleRippleState(rippleUv - vec2(0.0, uRippleTexelSize.y)).x; + float rippleTop = sampleRippleState(rippleUv + vec2(0.0, uRippleTexelSize.y)).x; + rippleGradient = vec2(rippleRight - rippleLeft, rippleTop - rippleBottom) * 0.5; } + float rippleGradientLength = length(rippleGradient); + float rippleGradientEnergy = smoothstep(0.003, 0.08, rippleGradientLength); + // 半浮点高度场保留连续波前;真实梯度直接决定方向和幅度,不放大量化噪声。 + vec2 rippleRefraction = + rippleGradient * mix(230.0, 335.0, uQuality) * uRippleDeformationStrength; + rippleRefraction /= max(uPresentationSize, vec2(1.0)); float frosted = step(1.5, uAppearance); + float rippleAppearanceScale = uAppearance > 1.5 ? 1.25 : (uAppearance > 0.5 ? 0.86 : 0.72); + rippleRefraction *= rippleAppearanceScale; for (int i = 0; i < 8; i++) { if (i >= uRectCount) break; @@ -888,109 +864,26 @@ void main() { 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; - vec2 pointerDeltaAspect = pointerDelta; - pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; - // 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。 - float pointerSpread = mix(26.0, 17.0, uQuality); - pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion); - float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan); - float pointerAlong = dot(-pointerDeltaAspect, wakeDirection); - float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular); - float sharedWakeTravel = - 0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion); - float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread); - float directionalPointerShape = - exp(-( - pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 + - pointerAcross * pointerAcross * pointerSpread * 1.35 - )); - float pointerEnergy = - clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0); - float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion); - float radialSharedWave = - exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity); - float directionalSharedWave = - exp(-( - pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 + - pointerAcross * pointerAcross * sharedWaveDensity * 2.2 - )); - float sharedWaveEnergy = - mix(radialSharedWave, directionalSharedWave, sharedDirectionality) * - clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) * - mix(1.0, 0.78, sharedDirectionality) * - uMotion * - uMotion; - vec2 wakeDelta = vUv - uPointer; - wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; - float wakeAlong = dot(wakeDelta, wakeDirection); - float wakeAcross = dot(wakeDelta, wakePerpendicular); - float wakeTravel = - 0.014 * dynamicRangeScale * - mix(0.82, 1.18, uQuality) * - mix(1.0, 1.45, uMotionExpansion); - float wakeWidth = - mix(0.027, 0.044, uQuality) * dynamicRangeScale * 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) * - dynamicRangeDensity * - 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, wakeEnergy * 0.82) - ), 0.0, 1.0); +${GLASS_FLUID_FRAGMENT_SURFACE_SHAPE} float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, 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) * dynamicRangeScale); - specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; - float specularAlong = dot(specularDelta, wakeDirection); - float specularAcross = dot(specularDelta, wakePerpendicular); - float singleSpecular = - exp(-( - specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) * dynamicRangeDensity + - specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) * dynamicRangeDensity - )) * - uMotion * - mix(1.0, 1.24, uMotionExpansion); - float localCaustic = singleSpecular * rectMask * surfaceDynamic * interactionMask; +${GLASS_FLUID_FRAGMENT_SURFACE_OPTICS} staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic; - vec2 sampleTranslation = - uPointerVelocity * - mix(0.055, 0.075, uQuality) * - uMotion * - uTranslationStrength; - dynamicRefraction += ( - sampleTranslation + - // 收紧高斯半径时补偿向量峰值,避免范围缩小同时削弱用户设置的形变强度。 - pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength / dynamicRangeScale + - trailRefraction * trailStrength + - temporalFlow * temporalStrength + - wakeRefraction - ) * rectMask * surfaceDynamic * interactionMask; +${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} + dynamicRefraction += rippleRefraction * rippleMode * rectMask * surfaceDynamic * interactionMask; edge = max(edge, edgeResponse * rectMask * surfaceDynamic); caustic = max(caustic, localCaustic); + caustic = max( + caustic, + rippleGradientEnergy * rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask + ); directionalReflection = max(directionalReflection, localDirectionalReflection); topPrism = max(topPrism, localTopPrism); backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption); materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask); + materialEnergy = max( + materialEnergy, + rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask + ); sharedMotionPresence = max( sharedMotionPresence, sharedWaveEnergy * rectMask * surfaceDynamic * interactionMask @@ -1146,7 +1039,6 @@ const PRESENTATION_RESIZE_SAMPLE_MS = 80 const PRESENTATION_RESIZE_REQUIRED_SAMPLES = 2 const SURFACE_STABILITY_MAX_FRAMES = 6 const SURFACE_STABILITY_REQUIRED_FRAMES = 2 -const FLOW_BUFFER_SCALE = 0.25 const SURFACE_TRANSITION_DURATION_MS = 96 const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000 @@ -1325,7 +1217,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const failedWallpaperPreparationKey = ref('') let three: ThreeModule | null = null let resources: GlassRendererResources | null = null - let flowResources: GlassFlowResources | null = null + let fluidDynamics: GlassFluidDynamics | null = null + let rippleResources: GlassRippleDynamics | null = null let frostPrefilterResources: GlassFrostPrefilterResources | null = null let activeTexture: Texture | null = null let activeFrostedTarget: WebGLRenderTarget | null = null @@ -1366,6 +1259,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) let lastTrailAt = Number.NEGATIVE_INFINITY let lastPointerX = window.innerWidth * 0.5 let lastPointerY = window.innerHeight * 0.5 + let suppressNextPointerVelocity = false let pointerTargetX = 0.5 let pointerTargetY = 0.5 let pointerPositionX = 0.5 @@ -1388,6 +1282,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) let lastRenderedScrollX = window.scrollX let lastRenderedScrollY = window.scrollY let unsubscribeInteractionSource: (() => void) | null = null + let interactionEventsAttached = false + let contextEventCanvas: HTMLCanvasElement | null = null + let contextRecoveryCanvas: HTMLCanvasElement | null = null let resizeObserver: ResizeObserver | null = null let surfaceMutationObserver: MutationObserver | null = null let observedSurfaces: HTMLElement[] = [] @@ -1410,6 +1307,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) let contextRecoveryPending = false let resumePromise: Promise | null = null let resumeVersion = 0 + let dynamicsGeneration = 0 const presentationSpace = options.surfaceSpace ?? 'fixed' const usesDynamicsOnly = () => presentationSpace === 'scroll' || (presentationSpace === 'fixed' && toValue(options.appearance) === 'frosted') @@ -1447,6 +1345,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180) if (scrollWallpaperSamplingSuppressed) return + if (hasRippleCapability() && rippleResources) { + interactionAnimating = false + cancelScheduledFrame() + rippleResources.reset() + resources.uniforms.uRippleTexture.value = rippleResources.texture + resources.uniforms.uHasRippleTexture.value = 0 + resetInteractionState() + } scrollWallpaperSamplingSuppressed = true syncWallpaperSamplingMode() document.documentElement.dataset.glassScrollPresentation = 'native' @@ -1762,82 +1668,145 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } /** 释放仅由高质量档使用的短时液态位移场。 */ - function disposeFlowResources() { - if (!flowResources) return + function disposeFluidDynamics() { + if (!fluidDynamics) return - flowResources.material.dispose() - flowResources.readTarget.dispose() - flowResources.writeTarget.dispose() - flowResources = null + fluidDynamics.dispose() + fluidDynamics = null if (resources) { resources.uniforms.uFlowTexture.value = null resources.uniforms.uHasFlowTexture.value = 0 } } + /** 释放只由水漾模式持有的时序场,并断开主材质纹理。 */ + function disposeRippleResources() { + dynamicsGeneration += 1 + rippleResources?.dispose() + rippleResources = null + if (!resources) return + + resources.uniforms.uRippleTexture.value = null + resources.uniforms.uHasRippleTexture.value = 0 + resources.uniforms.uRippleTexelSize.value.set(1, 1) + } + /** 根据当前质量档创建或释放共享 renderer 内的液态位移场。 */ - function syncFlowResources() { + function syncFluidDynamics() { if (!resources || !three) return const profile = getRenderProfile() - if (!hasDynamicCapability() || !profile.flowField) { - disposeFlowResources() + if (!hasFluidCapability() || !profile.flowField) { + disposeFluidDynamics() return } - if (flowResources) return + if (fluidDynamics) 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, + fluidDynamics = createGlassFluidDynamics({ + camera: resources.camera, + geometry: resources.geometry, + pointer: resources.uniforms.uPointer.value, + renderer: resources.renderer, + three, + velocity: resources.uniforms.uPointerVelocity.value, }) - 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 } + /** 只在水漾被选中时为当前 context 编译并分配独占 ping-pong 场。 */ + async function syncRippleResources() { + if (!resources || !three) return + if (!hasRippleCapability()) { + disposeRippleResources() + return + } + if (rippleResources) return + + const ownerResources = resources + const generation = ++dynamicsGeneration + const quality: GlassRippleQuality = toValue(options.quality) === 'high' ? 'high' : 'balanced' + let ripple: GlassRippleDynamics + try { + ripple = await createGlassRippleDynamics({ + camera: ownerResources.camera, + geometry: ownerResources.geometry, + quality, + renderer: ownerResources.renderer, + three, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + }) + } catch (error) { + if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) return + throw error + } + if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) { + ripple.dispose() + return + } + + rippleResources = ripple + ripple.setParameters( + toValue(options.translationStrength ?? getLegacyDynamicStrength()), + toValue(options.flowStrength ?? getLegacyDynamicStrength()), + ) + ownerResources.uniforms.uRippleTexture.value = ripple.texture + ownerResources.uniforms.uRippleTexelSize.value.copy(ripple.texelSize) + ownerResources.uniforms.uHasRippleTexture.value = ripple.texture ? 1 : 0 + } + + /** 模式切换时同步释放旧策略、清空输入历史并在资源就绪后恢复订阅。 */ + async function syncDynamicsMode() { + if (!resources) return + + interactionAnimating = false + activeTouchIdentifier = null + cancelScheduledFrame() + removeInteractionEvents() + resetInteractionState() + lastPointerAt = 0 + lastPointerX = window.innerWidth * 0.5 + lastPointerY = window.innerHeight * 0.5 + suppressNextPointerVelocity = true + wakeDirection = { x: 0, y: -1 } + + const mode = getDynamicsMode() + resources.uniforms.uDynamicsMode.value = getDynamicsModeUniformValue() + resources.uniforms.uTranslationStrength.value = mode === 'fluid' ? getTranslationStrengthScale() : 0 + resources.uniforms.uDeformationStrength.value = mode === 'fluid' ? getDeformationStrengthScale() : 0 + resources.uniforms.uFlowStrength.value = mode === 'fluid' ? getFlowStrengthScale() : 0 + resources.uniforms.uMotionExpansion.value = mode === 'fluid' ? getMotionExpansion() : 0 + resources.uniforms.uMaxRefractionPixels.value = getMaxRefractionPixels() + resources.uniforms.uRippleDeformationStrength.value = + mode === 'ripple' + ? Math.min(1, Math.max(0, toValue(options.deformationStrength ?? getLegacyDynamicStrength()) / 100)) + : 0 + resources.uniforms.uTrailCount.value = mode === 'fluid' ? getRenderProfile().trailCount : 0 + + if (mode === 'fluid') { + disposeRippleResources() + syncFluidDynamics() + } else if (mode === 'ripple') { + disposeFluidDynamics() + await syncRippleResources() + } else { + disposeFluidDynamics() + disposeRippleResources() + } + + if (!resources || getDynamicsMode() !== mode) return + + if (mode !== 'off') setupInteractionEvents() + resizeRenderer() + scheduleFrame() + } + function renderFrame(timestamp = performance.now(), advanceFlow = true) { if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return updateWallpaperTransition(timestamp) - if (flowResources && advanceFlow) { - 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 (fluidDynamics && advanceFlow) { + resources.uniforms.uFlowTexture.value = fluidDynamics.step() } if (presentationSpace === 'scroll') { const { height: presentationHeight } = getCommittedPresentationSize() @@ -2361,30 +2330,50 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT) } + function getDynamicsMode(): ThemeCustomizerGlassDynamicsMode { + if (!toValue(options.dynamicsActive ?? true)) return 'off' + + return toValue(options.dynamicsMode ?? 'fluid') + } + function hasDynamicCapability() { - return toValue(options.dynamicsActive ?? true) + return getDynamicsMode() !== 'off' + } + + function hasFluidCapability() { + return getDynamicsMode() === 'fluid' + } + + function hasRippleCapability() { + return getDynamicsMode() === 'ripple' + } + + function getDynamicsModeUniformValue() { + const mode = getDynamicsMode() + + return mode === 'fluid' ? 0 : mode === 'ripple' ? 1 : 2 } function getTranslationStrengthScale() { - if (!hasDynamicCapability()) return 0 + if (!hasFluidCapability()) return 0 return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength())) } function getDeformationStrengthScale() { - if (!hasDynamicCapability()) return 0 + if (!hasFluidCapability()) return 0 return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength())) } function getFlowStrengthScale() { - if (!hasDynamicCapability()) return 0 + if (!hasFluidCapability()) return 0 return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength())) } function getMotionExpansion() { - if (!hasDynamicCapability()) return 0 + if (!hasFluidCapability()) return 0 return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength())) } @@ -2392,6 +2381,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) function getMaxRefractionPixels() { if (!hasDynamicCapability()) return 0 + if (hasRippleCapability()) { + const deformation = Math.min( + 1, + Math.max(0, toValue(options.deformationStrength ?? getLegacyDynamicStrength()) / 100), + ) + + return (toValue(options.quality) === 'high' ? 40 : 28) * deformation + } + return getGlassOpticalMaxRefractionPixels( getRenderProfile().maxRefractionPixels, toValue(options.deformationStrength ?? getLegacyDynamicStrength()), @@ -2444,17 +2442,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) 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)) - const flowBufferChanged = - flowResources.readTarget.width !== flowWidth || flowResources.readTarget.height !== flowHeight - if (flowBufferChanged) { - 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) + fluidDynamics?.resize(buffer.width, buffer.height, viewportWidth, viewportHeight) + if (rippleResources) { + rippleResources.resize(viewportWidth, viewportHeight) + resources.uniforms.uRippleTexture.value = rippleResources.texture + resources.uniforms.uRippleTexelSize.value.copy(rippleResources.texelSize) + resources.uniforms.uHasRippleTexture.value = rippleResources.texture ? 1 : 0 } syncCoverScale(viewportWidth, viewportHeight) updateSurfaceUniforms(performance.now(), false) @@ -2613,10 +2606,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) resources.uniforms.uMotion.value = 0 resources.uniforms.uPointerVelocity.value.set(0, 0) for (const trail of resources.uniforms.uTrail.value) trail.z = 0 - if (flowResources) { - flowResources.uniforms.uDecay.value = 0 - flowResources.uniforms.uInjection.value = 0 - } + fluidDynamics?.clearInput() } function renderInteractionFrame(timestamp: number) { @@ -2626,6 +2616,26 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) return } + if (hasRippleCapability()) { + writeSurfaceUniforms(timestamp) + const keepAnimating = advanceRipple(timestamp) + renderFrame(timestamp, false) + if (!keepAnimating) { + resetInteractionState() + interactionAnimating = false + return + } + + animationFrame = requestAnimationFrame(renderInteractionFrame) + return + } + if (!hasFluidCapability()) { + resetInteractionState() + renderFrame(timestamp, false) + interactionAnimating = false + return + } + const profile = getRenderProfile() const elapsed = Math.max(0, timestamp - lastInteractionAt) const flowScale = getFlowStrengthScale() @@ -2645,16 +2655,25 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) lastInteractionFrameAt = timestamp // 收敛期只衰减能量;输入方向保留到最终清场,避免低能量归一化时发生方向跳变。 resources.uniforms.uMotion.value = motion - if (flowResources) { - flowResources.uniforms.uDecay.value = getGlassOpticalDecay(profile.flowHalfLife, delta) - flowResources.uniforms.uInjection.value = pendingFlowInjection - } + fluidDynamics?.setFrameParameters(getGlassOpticalDecay(profile.flowHalfLife, delta), pendingFlowInjection) pendingFlowInjection = 0 renderFrame() - if (flowResources) flowResources.uniforms.uInjection.value = 0 + fluidDynamics?.finishFrame() animationFrame = requestAnimationFrame(renderInteractionFrame) } + /** 推进并重新绑定当前 ripple 场,确保 deadline 清场与主材质纹理状态原子提交。 */ + function advanceRipple(timestamp: number) { + if (!resources || !rippleResources || !hasRippleCapability()) return false + + const keepAnimating = rippleResources.step(timestamp) + resources.uniforms.uRippleTexture.value = rippleResources.texture + resources.uniforms.uRippleTexelSize.value.copy(rippleResources.texelSize) + resources.uniforms.uHasRippleTexture.value = rippleResources.texture ? 1 : 0 + + return keepAnimating + } + function startInteractionAnimation() { if (interactionAnimating) return @@ -2671,7 +2690,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) timestamp: number, velocityOverride?: { x: number; y: number }, ) { - if (!hasDynamicCapability()) return + if ( + !hasDynamicCapability() || + (hasRippleCapability() && presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed) + ) { + return + } const viewportWidth = Math.max(window.innerWidth, 1) const viewportHeight = Math.max(window.innerHeight, 1) @@ -2681,14 +2705,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) 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 suppressVelocity = suppressNextPointerVelocity && velocityOverride === undefined + const velocityX = + velocityOverride?.x ?? + (suppressVelocity ? 0 : ((clientX - lastPointerX) / viewportWidth) * Math.min(2, 16.67 / elapsed)) + const velocityY = + velocityOverride?.y ?? + (suppressVelocity ? 0 : (-(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 interactionTarget = findInteractionTarget(point.x, point.y) lastPointerX = clientX lastPointerY = clientY lastPointerAt = timestamp + suppressNextPointerVelocity = false if (!resources || !interactionTarget || interactionTarget.surface.mode === 'static-material') return const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches @@ -2699,6 +2729,31 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) updateInteractionClips() writeSurfaceUniforms(timestamp) } + const scaledVelocity = { x: velocityX * velocityScale, y: velocityY * velocityScale } + if (hasRippleCapability()) { + if (reducedMotion || !rippleResources) { + resetInteractionState() + scheduleFrame() + return + } + + rippleResources.inject({ + direction: scaledVelocity, + point: { + x: clientX / viewportWidth, + y: 1 - clientY / viewportHeight, + }, + speed: Math.min(1, velocityLength / 0.09), + timestamp, + }) + resources.uniforms.uMotion.value = 0 + resources.uniforms.uPointerVelocity.value.set(0, 0) + resources.uniforms.uTrailCount.value = 0 + lastInteractionAt = timestamp + startInteractionAnimation() + return + } + const startsInteraction = !interactionAnimating const hasTrailAnchor = Number.isFinite(lastTrailAt) const normalizedX = point.x / Math.max(presentation.width, 1) @@ -2713,7 +2768,6 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) 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, @@ -2737,10 +2791,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) 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 - } + fluidDynamics?.clearInput() scheduleFrame() return } @@ -2975,8 +3026,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) resizeRenderer() updateSurfaceUniforms() - resetInteractionState() - renderFrame(performance.now()) + const timestamp = performance.now() + const keepRippleAnimating = hasRippleCapability() ? advanceRipple(timestamp) : false + if (!keepRippleAnimating) resetInteractionState() + renderFrame(timestamp, !hasRippleCapability()) + if (keepRippleAnimating) { + interactionAnimating = true + animationFrame = requestAnimationFrame(renderInteractionFrame) + } scheduleWallpaperTransition() })() @@ -3023,10 +3080,11 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) function handleContextLost(event: Event) { event.preventDefault() - const canvas = options.canvas.value + const canvas = contextEventCanvas ?? options.canvas.value disposeRenderer(false) contextRecoveryPending = true // 丢失的 context 已回收全部 GPU 对象;在恢复前释放旧 Three 状态,避免恢复后删除失效句柄。 + contextRecoveryCanvas = canvas canvas?.addEventListener('webglcontextrestored', handleContextRestored, { once: true }) updateRendererState('fallback') } @@ -3035,6 +3093,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) if (!contextRecoveryPending) return contextRecoveryPending = false + contextRecoveryCanvas?.removeEventListener('webglcontextrestored', handleContextRestored) + contextRecoveryCanvas = null void initializeRenderer(false) } @@ -3087,7 +3147,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) }) } - function setupEvents() { + function setupInteractionEvents() { + if (!hasDynamicCapability() || interactionEventsAttached) return + + interactionEventsAttached = true if (options.interactionSource) { unsubscribeInteractionSource = options.interactionSource.subscribe(presentationSpace, handleInteractionEvent) } else { @@ -3097,6 +3160,43 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) window.addEventListener('touchend', handleTouchEnd, { passive: true }) window.addEventListener('touchcancel', handleTouchEnd, { passive: true }) } + } + + function removeInteractionEvents() { + if (!interactionEventsAttached) return + + interactionEventsAttached = false + unsubscribeInteractionSource?.() + unsubscribeInteractionSource = null + if (options.interactionSource) return + + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('touchstart', handleTouchStart) + window.removeEventListener('touchmove', handleTouchMove) + window.removeEventListener('touchend', handleTouchEnd) + window.removeEventListener('touchcancel', handleTouchEnd) + } + + /** context 生命周期在异步 shader 编译前生效,确保初始化中的丢失也进入统一恢复状态机。 */ + function setupContextEvents(canvas: HTMLCanvasElement) { + if (contextEventCanvas === canvas) return + + removeContextEvents() + contextEventCanvas = canvas + canvas.addEventListener('webglcontextlost', handleContextLost) + canvas.addEventListener('webglcontextrestored', handleContextRestored) + } + + function removeContextEvents() { + contextEventCanvas?.removeEventListener('webglcontextlost', handleContextLost) + contextEventCanvas?.removeEventListener('webglcontextrestored', handleContextRestored) + contextEventCanvas = null + contextRecoveryCanvas?.removeEventListener('webglcontextrestored', handleContextRestored) + contextRecoveryCanvas = null + } + + function setupEvents() { + setupInteractionEvents() window.addEventListener('resize', handleWindowResize, { passive: true }) window.addEventListener('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true }) window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true }) @@ -3108,20 +3208,10 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) window.addEventListener('scroll', handleScroll, { capture: true, passive: true }) window.addEventListener('scrollend', handleScrollEnd, { passive: true }) } - options.canvas.value?.addEventListener('webglcontextlost', handleContextLost) - options.canvas.value?.addEventListener('webglcontextrestored', handleContextRestored) } function removeEvents() { - 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) - } + removeInteractionEvents() window.removeEventListener('resize', handleWindowResize) window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true) window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true) @@ -3133,8 +3223,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scrollend', handleScrollEnd) } - options.canvas.value?.removeEventListener('webglcontextlost', handleContextLost) - options.canvas.value?.removeEventListener('webglcontextrestored', handleContextRestored) + removeContextEvents() } function disposeRenderer(releaseContext = true) { @@ -3216,7 +3305,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) releasePreparedWallpaper() clearPreparedWallpaperFailure() - disposeFlowResources() + disposeFluidDynamics() + disposeRippleResources() disposeFrostPrefilterResources() if (resources) { resources.geometry.dispose() @@ -3703,10 +3793,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) uCoverScale: { value: new three.Vector2(1, 1) }, uDeformationStrength: { value: getDeformationStrengthScale() }, uDynamicsOnly: { value: usesDynamicsOnly() ? 1 : 0 }, + uDynamicsMode: { value: getDynamicsModeUniformValue() }, uFlowTexture: { value: null }, uFlowStrength: { value: getFlowStrengthScale() }, uHasFlowTexture: { value: 0 }, uHasFrostedTexture: { value: 0 }, + uHasRippleTexture: { value: 0 }, uHasWallpaperTexture: { value: 0 }, uInteractionRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, uInteractionRectCount: { value: 0 }, @@ -3721,6 +3813,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) uPreviousWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure }, uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 }, uReflectionStrength: { value: getReflectionStrengthScale() }, + uRippleDeformationStrength: { + value: hasRippleCapability() + ? Math.min(1, Math.max(0, toValue(options.deformationStrength ?? getLegacyDynamicStrength()) / 100)) + : 0, + }, + uRippleTexelSize: { value: new three.Vector2(1, 1) }, + uRippleTexture: { value: null }, uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, uRectCount: { value: 0 }, uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, @@ -3743,7 +3842,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) }, uTranslationStrength: { value: getTranslationStrengthScale() }, uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) }, - uTrailCount: { value: hasDynamicCapability() ? getRenderProfile().trailCount : 0 }, + uTrailCount: { value: hasFluidCapability() ? getRenderProfile().trailCount : 0 }, uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) }, uScrollOffset: { value: new three.Vector2(0, 0) }, uWakeDirection: { value: new three.Vector2(0, -1) }, @@ -3762,7 +3861,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) mesh.frustumCulled = false scene.add(mesh) resources = { camera, geometry, material, mesh, renderer, scene, uniforms } - syncFlowResources() + const ownerResources = resources + setupContextEvents(canvas) + syncFluidDynamics() + await syncRippleResources() + if ( + version !== loadVersion || + resources !== ownerResources || + !toValue(options.active) || + options.canvas.value !== canvas + ) { + return + } setupObservers() setupEvents() @@ -3774,6 +3884,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } } + /** 由复合层显式重建已释放的 renderer;失败仍进入 fallback,不安排自动重试。 */ + function retryAfterFailure() { + if (contextRecoveryPending) return Promise.resolve() + + return initializeRenderer() + } + /** 运行中切换减少透明度时立即释放或恢复光学资源。 */ function handleReducedTransparencyChange(event: MediaQueryListEvent) { if (!toValue(options.active)) return @@ -3884,27 +4001,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) ) watch( - () => toValue(options.dynamicsActive ?? true), - active => { + () => [toValue(options.dynamicsActive ?? true), toValue(options.dynamicsMode ?? 'fluid')] as const, + async () => { if (!resources) return - interactionAnimating = false - activeTouchIdentifier = null - cancelScheduledFrame() - resetInteractionState() - resources.uniforms.uTranslationStrength.value = active ? getTranslationStrengthScale() : 0 - resources.uniforms.uDeformationStrength.value = active ? getDeformationStrengthScale() : 0 - resources.uniforms.uFlowStrength.value = active ? getFlowStrengthScale() : 0 - resources.uniforms.uMotionExpansion.value = active ? getMotionExpansion() : 0 - resources.uniforms.uMaxRefractionPixels.value = active - ? getGlassOpticalMaxRefractionPixels( - getRenderProfile().maxRefractionPixels, - toValue(options.deformationStrength ?? getLegacyDynamicStrength()), - ) - : 0 - resources.uniforms.uTrailCount.value = active ? getRenderProfile().trailCount : 0 - syncFlowResources() - scheduleFrame() + const version = loadVersion + try { + await syncDynamicsMode() + } catch (error) { + fallbackFromCurrentLoad(version, '玻璃动态策略切换失败,已回退标准材质:', error) + } }, ) @@ -3918,30 +4024,41 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const applyQuality = () => { if (!resources || toValue(options.quality) !== quality) return - resources.uniforms.uMaxRefractionPixels.value = hasDynamicCapability() - ? getGlassOpticalMaxRefractionPixels( - nextProfile.maxRefractionPixels, - toValue(options.deformationStrength ?? getLegacyDynamicStrength()), - ) - : 0 + resources.uniforms.uMaxRefractionPixels.value = getMaxRefractionPixels() resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0 - resources.uniforms.uTrailCount.value = hasDynamicCapability() ? nextProfile.trailCount : 0 + resources.uniforms.uTrailCount.value = hasFluidCapability() ? nextProfile.trailCount : 0 interactionAnimating = false cancelScheduledFrame() resetInteractionState() - syncFlowResources() - resizeRenderer() + if (hasRippleCapability()) disposeRippleResources() + } + const finishQualityChange = async () => { + if (!resources || toValue(options.quality) !== quality) return + + await syncDynamicsMode() } if (!profileRequiresTextureReload(previousProfile, nextProfile)) { applyQuality() - scheduleFrame() + try { + await finishQualityChange() + } catch (error) { + fallbackFromCurrentLoad(loadVersion, '玻璃动态质量切换失败,已回退标准材质:', error) + } return } invalidatePreparedWallpaper() await refreshWallpaper('玻璃光学质量切换失败,已保留当前材质:', applyQuality) - if (toValue(options.quality) === quality) preparePendingWallpaper() + if (toValue(options.quality) === quality) { + try { + await finishQualityChange() + } catch (error) { + fallbackFromCurrentLoad(loadVersion, '玻璃动态质量切换失败,已回退标准材质:', error) + return + } + preparePendingWallpaper() + } }, ) @@ -3975,18 +4092,20 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) ]) => { if (!resources) return - const dynamicsActive = hasDynamicCapability() - resources.uniforms.uTranslationStrength.value = dynamicsActive + const fluidActive = hasFluidCapability() + resources.uniforms.uTranslationStrength.value = fluidActive ? getGlassOpticalTranslationStrengthScale(translationStrength) : 0 - resources.uniforms.uDeformationStrength.value = dynamicsActive + resources.uniforms.uDeformationStrength.value = fluidActive ? getGlassOpticalDeformationStrengthScale(deformationStrength) : 0 - resources.uniforms.uFlowStrength.value = dynamicsActive ? getGlassOpticalFlowStrengthScale(flowStrength) : 0 - resources.uniforms.uMotionExpansion.value = dynamicsActive ? getGlassOpticalMotionExpansion(flowStrength) : 0 - resources.uniforms.uMaxRefractionPixels.value = dynamicsActive - ? getGlassOpticalMaxRefractionPixels(getRenderProfile().maxRefractionPixels, deformationStrength) + resources.uniforms.uFlowStrength.value = fluidActive ? getGlassOpticalFlowStrengthScale(flowStrength) : 0 + resources.uniforms.uMotionExpansion.value = fluidActive ? getGlassOpticalMotionExpansion(flowStrength) : 0 + resources.uniforms.uMaxRefractionPixels.value = getMaxRefractionPixels() + resources.uniforms.uRippleDeformationStrength.value = hasRippleCapability() + ? Math.min(1, Math.max(0, deformationStrength / 100)) : 0 + rippleResources?.setParameters(translationStrength, flowStrength) resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength) const materialResponse = getGlassMaterialResponse(toValue(options.appearance), transparencyStrength) resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility @@ -3994,7 +4113,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) resources.uniforms.uSurfaceDensity.value = materialResponse.surfaceDensity resources.uniforms.uTintDensity.value = materialResponse.tintDensity resources.uniforms.uTransmissionStrength.value = getGlassOpticalTransmissionStrength(transmissionStrength) - if (resources.uniforms.uFlowStrength.value <= 0 && interactionAnimating) { + if (fluidActive && resources.uniforms.uFlowStrength.value <= 0 && interactionAnimating) { interactionAnimating = false cancelScheduledFrame() pendingFlowInjection = 0 @@ -4086,6 +4205,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) preparedWallpaperUrl, preparedWallpaperRevision, renderedFrames, + retryAfterFailure, rollbackPreparedWallpaperActivation, state, } diff --git a/src/composables/useThemeCustomizer.ts b/src/composables/useThemeCustomizer.ts index 5d6b83ed..bd728439 100644 --- a/src/composables/useThemeCustomizer.ts +++ b/src/composables/useThemeCustomizer.ts @@ -70,6 +70,8 @@ export const themeCustomizerShadowLevels = [ export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical' export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted' +/** 玻璃动态效果的持久化选择;关闭模式仍保留用户配置的动态参数。 */ +export type ThemeCustomizerGlassDynamicsMode = 'fluid' | 'ripple' | 'off' export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high' export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small' export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number] @@ -79,6 +81,8 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple export interface ThemeCustomizerSettings { /** 玻璃主题的材质语义,与渲染质量保持独立。 */ glassAppearance: ThemeCustomizerGlassAppearance + /** 玻璃动态效果模式,与六参数预设矩阵保持独立。 */ + glassDynamicsMode: ThemeCustomizerGlassDynamicsMode /** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */ glassDeformationStrength: number /** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */ @@ -117,6 +121,7 @@ type VuetifyThemeApi = ReturnType const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted'] +const validGlassDynamicsModes: ThemeCustomizerGlassDynamicsMode[] = ['fluid', 'ripple', 'off'] const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid'] const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high'] const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced' @@ -138,6 +143,7 @@ type DefaultGlassCustomizerSettings = Pick< ThemeCustomizerSettings, | 'glassAppearance' | 'glassDeformationStrength' + | 'glassDynamicsMode' | 'glassFlowStrength' | 'glassPreset' | 'glassPresetOverrides' @@ -176,6 +182,7 @@ export function getDefaultGlassCustomizerSettings( return { glassAppearance: 'clear', glassDeformationStrength: glassParameters.deformation, + glassDynamicsMode: 'ripple', glassFlowStrength: glassParameters.flow, glassPreset: 'natural', glassPresetOverrides: {}, @@ -287,6 +294,9 @@ function normalizeThemeCustomizerSettings( settings.glassMotionStrength, fallback.glassDeformationStrength, ), + glassDynamicsMode: validGlassDynamicsModes.includes(settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode) + ? (settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode) + : fallback.glassDynamicsMode, glassFlowStrength: normalizeMigratedGlassStrength( settings.glassFlowStrength, settings.glassMotionStrength, @@ -368,6 +378,7 @@ type ThemeCustomizerGlassSettings = Pick< ThemeCustomizerSettings, | 'glassAppearance' | 'glassDeformationStrength' + | 'glassDynamicsMode' | 'glassFlowStrength' | 'glassPreset' | 'glassPresetOverrides' @@ -382,6 +393,7 @@ const effectiveGlassSettings = computed(() => ({ glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance, glassDeformationStrength: glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength, + glassDynamicsMode: glassPreviewState.value?.glassDynamicsMode ?? settingsState.value.glassDynamicsMode, glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength, glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset, glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides, @@ -591,6 +603,7 @@ export function previewGlassSettings(patch: Partial { + this.width = width + this.height = height + }) + readonly texture = {} + width: number + + constructor( + width: number, + height: number, + readonly options: Record, + ) { + this.height = height + this.width = width + FakeRenderTarget.instances.push(this) + } +} + +class FakeShaderMaterial { + static instances: FakeShaderMaterial[] = [] + + readonly dispose = vi.fn() + readonly fragmentShader: string + readonly uniforms: Record + + constructor(options: { fragmentShader: string; uniforms: Record }) { + this.fragmentShader = options.fragmentShader + this.uniforms = options.uniforms + FakeShaderMaterial.instances.push(this) + } +} + +class FakeScene { + readonly children: FakeMesh[] = [] + + add(mesh: FakeMesh) { + this.children.push(mesh) + } +} + +class FakeMesh { + frustumCulled = true + + constructor( + readonly geometry: unknown, + readonly material: FakeShaderMaterial, + ) {} +} + +function createFluidHarness() { + let currentTarget: FakeRenderTarget | null = null + const pointer = new FakeVector2(0.25, 0.75) + const velocity = new FakeVector2(0.1, -0.2) + const renderer = { + render: vi.fn(), + setRenderTarget: vi.fn((target: FakeRenderTarget | null) => { + currentTarget = target + }), + setScissorTest: vi.fn(), + } + const three = { + LinearFilter: 1001, + Mesh: FakeMesh, + Scene: FakeScene, + ShaderMaterial: FakeShaderMaterial, + Vector2: FakeVector2, + WebGLRenderTarget: FakeRenderTarget, + } as unknown as typeof import('three') + + return { + create: () => + createGlassFluidDynamics({ + camera: {} as never, + geometry: {} as never, + pointer: pointer as never, + renderer: renderer as never, + three, + velocity: velocity as never, + }), + getCurrentTarget: () => currentTarget, + pointer, + renderer, + velocity, + } +} + +beforeEach(() => { + FakeRenderTarget.instances = [] + FakeShaderMaterial.instances = [] +}) + +describe('glass fluid dynamics', () => { + it('owns exactly one two-target field and reuses the shared pointer vectors', () => { + const harness = createFluidHarness() + const dynamics = harness.create() + const material = FakeShaderMaterial.instances[0] + + expect(FakeRenderTarget.instances).toHaveLength(2) + expect(FakeRenderTarget.instances.map(target => target.options)).toEqual([ + { + depthBuffer: false, + magFilter: 1001, + minFilter: 1001, + stencilBuffer: false, + }, + { + depthBuffer: false, + magFilter: 1001, + minFilter: 1001, + stencilBuffer: false, + }, + ]) + expect(material.fragmentShader).toBe(GLASS_FLUID_FIELD_FRAGMENT_SHADER) + expect(material.uniforms.uPointer.value).toBe(harness.pointer) + expect(material.uniforms.uVelocity.value).toBe(harness.velocity) + + dynamics.dispose() + }) + + it('resizes, advances, swaps and clears its private temporal field', () => { + const harness = createFluidHarness() + const dynamics = harness.create() + const material = FakeShaderMaterial.instances[0] + const [firstTarget, secondTarget] = FakeRenderTarget.instances + + dynamics.resize(800, 600, 1200, 600) + expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([ + [200, 150], + [200, 150], + ]) + expect(material.uniforms.uTexelSize.value).toMatchObject({ x: 1 / 200, y: 1 / 150 }) + expect(material.uniforms.uViewportAspect.value).toBe(2) + + dynamics.setFrameParameters(0.8, 0.6) + const texture = dynamics.step() + + expect(harness.renderer.setScissorTest).toHaveBeenCalledWith(false) + expect(material.uniforms.uPrevious.value).toBe(firstTarget.texture) + expect(harness.renderer.setRenderTarget.mock.calls).toEqual([[secondTarget], [null]]) + expect(harness.renderer.render).toHaveBeenCalledOnce() + expect(harness.getCurrentTarget()).toBeNull() + expect(texture).toBe(secondTarget.texture) + + dynamics.finishFrame() + expect(material.uniforms.uInjection.value).toBe(0) + expect(material.uniforms.uDecay.value).toBe(0.8) + + dynamics.clearInput() + expect(material.uniforms.uDecay.value).toBe(0) + expect(material.uniforms.uInjection.value).toBe(0) + + dynamics.dispose() + dynamics.dispose() + expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true) + expect(material.dispose).toHaveBeenCalledOnce() + }) + + it('keeps the established field injection and decay equations', () => { + expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('previousEnergy * uDecay') + expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 437.500') + expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 262.500') + expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('injection * 0.44') + expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).not.toContain('uImpulse') + }) +}) diff --git a/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts b/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts new file mode 100644 index 00000000..325d513a --- /dev/null +++ b/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts @@ -0,0 +1,385 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createGlassRippleDynamics, + RIPPLE_FRAGMENT_SHADER, + type GlassRippleQuality, +} from '@/rendering/glass/glassRippleDynamics' + +class FakeVector2 { + constructor( + public x = 0, + public y = 0, + ) {} + + copy(value: FakeVector2) { + return this.set(value.x, value.y) + } + + set(x: number, y: number) { + this.x = x + this.y = y + + return this + } +} + +class FakeRenderTarget { + static instances: FakeRenderTarget[] = [] + + readonly dispose = vi.fn() + height = 1 + readonly setSize = vi.fn((width: number, height: number) => { + this.width = width + this.height = height + }) + readonly texture: Record + width = 1 + + constructor( + _width: number, + _height: number, + readonly options: Record, + ) { + this.texture = { + format: options.format, + magFilter: options.magFilter, + minFilter: options.minFilter, + type: options.type, + wrapS: options.wrapS, + wrapT: options.wrapT, + } + FakeRenderTarget.instances.push(this) + } +} + +class FakeShaderMaterial { + static instances: FakeShaderMaterial[] = [] + + readonly dispose = vi.fn() + readonly fragmentShader: string + readonly uniforms: Record + + constructor(options: { fragmentShader: string; uniforms: Record }) { + this.fragmentShader = options.fragmentShader + this.uniforms = options.uniforms + FakeShaderMaterial.instances.push(this) + } +} + +class FakeScene { + readonly children: FakeMesh[] = [] + + add(mesh: FakeMesh) { + this.children.push(mesh) + } +} + +class FakeMesh { + frustumCulled = true + + constructor( + readonly geometry: unknown, + readonly material: FakeShaderMaterial, + ) {} +} + +interface RenderSnapshot { + direction: { x: number; y: number } + energyDecay: number + heightDecay: number + impulse: number + impulseCenter: { x: number; y: number } + impulseOffset: number + impulseSigma: number + impulseSpeed: number + reset: number + step: number + target: FakeRenderTarget | null + velocityDecay: number +} + +function createRippleHarness( + quality: GlassRippleQuality = 'balanced', + compileAsync = vi.fn().mockResolvedValue(undefined), + supportsHalfFloatTarget = true, +) { + let currentTarget: FakeRenderTarget | null = null + const snapshots: RenderSnapshot[] = [] + const renderer = { + compileAsync, + extensions: { + has: vi.fn(() => supportsHalfFloatTarget), + }, + getRenderTarget: vi.fn(() => currentTarget), + render: vi.fn((scene: FakeScene) => { + const uniforms = scene.children[0].material.uniforms + snapshots.push({ + direction: { + x: (uniforms.uImpulseDirection.value as FakeVector2).x, + y: (uniforms.uImpulseDirection.value as FakeVector2).y, + }, + energyDecay: uniforms.uEnergyDecay.value as number, + heightDecay: uniforms.uHeightDecay.value as number, + impulse: uniforms.uImpulse.value as number, + impulseCenter: { + x: (uniforms.uImpulseCenter.value as FakeVector2).x, + y: (uniforms.uImpulseCenter.value as FakeVector2).y, + }, + impulseOffset: uniforms.uImpulseOffset.value as number, + impulseSigma: uniforms.uImpulseSigma.value as number, + impulseSpeed: uniforms.uImpulseSpeed.value as number, + reset: uniforms.uReset.value as number, + step: uniforms.uStep.value as number, + target: currentTarget, + velocityDecay: uniforms.uVelocityDecay.value as number, + }) + }), + setRenderTarget: vi.fn((target: FakeRenderTarget | null) => { + currentTarget = target + }), + setScissorTest: vi.fn(), + } + const three = { + ClampToEdgeWrapping: 1001, + HalfFloatType: 1005, + LinearFilter: 1002, + Mesh: FakeMesh, + RGBAFormat: 1003, + Scene: FakeScene, + ShaderMaterial: FakeShaderMaterial, + UnsignedByteType: 1004, + Vector2: FakeVector2, + WebGLRenderTarget: FakeRenderTarget, + } as unknown as typeof import('three') + + return { + create: () => + createGlassRippleDynamics({ + camera: {} as never, + geometry: {} as never, + quality, + renderer: renderer as never, + three, + viewportHeight: 800, + viewportWidth: 1200, + }), + renderer, + snapshots, + } +} + +beforeEach(() => { + FakeRenderTarget.instances = [] + FakeShaderMaterial.instances = [] +}) + +describe('glass ripple dynamics', () => { + it('uses one bounded half-float ping-pong field when the renderer supports it', async () => { + const harness = createRippleHarness() + const dynamics = await harness.create() + + expect(FakeRenderTarget.instances).toHaveLength(2) + expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([ + [192, 128], + [192, 128], + ]) + for (const target of FakeRenderTarget.instances) { + expect(target.options).toMatchObject({ + depthBuffer: false, + format: 1003, + magFilter: 1002, + minFilter: 1002, + stencilBuffer: false, + type: 1005, + wrapS: 1001, + wrapT: 1001, + }) + expect(target.texture.generateMipmaps).toBe(false) + } + expect(harness.renderer.compileAsync).toHaveBeenCalledOnce() + expect(harness.snapshots).toHaveLength(2) + expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true) + expect(new Set(harness.snapshots.map(snapshot => snapshot.target))).toEqual(new Set(FakeRenderTarget.instances)) + expect(dynamics.texture).toBeNull() + expect(dynamics.texelSize.x).toBeCloseTo(1 / 192) + expect(dynamics.texelSize.y).toBeCloseTo(1 / 128) + + dynamics.dispose() + expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true) + expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce() + }) + + it('keeps the documented neutral encoding, stencil weights and bounded impulse kernel', () => { + expect(RIPPLE_FRAGMENT_SHADER).toContain('vec4(0.5, 0.5, 0.0, 1.0)') + expect(RIPPLE_FRAGMENT_SHADER).toContain('sampleValue.b < (1.0 / 255.0)') + expect(RIPPLE_FRAGMENT_SHADER).toContain('cardinal1 * 0.72 + cardinal2 * 0.28') + expect(RIPPLE_FRAGMENT_SHADER).toContain( + 'cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12', + ) + expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalRadius = length(vec2(along * 0.72, across * 1.24))') + expect(RIPPLE_FRAGMENT_SHADER).toContain('float centerRelease = smoothstep(0.0, 0.55, normalizedRadius)') + expect(RIPPLE_FRAGMENT_SHADER).toContain('float annularCore = normalizedRadius * core') + expect(RIPPLE_FRAGMENT_SHADER).toContain( + 'float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse', + ) + expect(RIPPLE_FRAGMENT_SHADER).not.toContain('0.58 * core') + expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalImpulse = clamp(') + expect(RIPPLE_FRAGMENT_SHADER).toContain('mix(radialImpulse, directionalImpulse') + expect(RIPPLE_FRAGMENT_SHADER).toContain('impulse * mix(0.52, 0.82, speedResponse)') + expect(RIPPLE_FRAGMENT_SHADER).not.toContain('uHeightDecay + impulse') + }) + + it('resizes in viewport space, clears the field and releases every owned resource', async () => { + const harness = createRippleHarness('high') + const dynamics = await harness.create() + harness.snapshots.length = 0 + + dynamics.inject({ + direction: { x: 1, y: 0 }, + point: { x: 0.4, y: 0.6 }, + speed: 0.8, + timestamp: 100, + }) + expect(dynamics.step(116.667)).toBe(true) + expect(dynamics.texture).not.toBeNull() + + dynamics.resize(1600, 900) + + expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([ + [400, 225], + [400, 225], + ]) + expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true) + expect(dynamics.texture).toBeNull() + expect(dynamics.texelSize.x).toBeCloseTo(1 / 400) + expect(dynamics.texelSize.y).toBeCloseTo(1 / 225) + + dynamics.dispose() + expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true) + expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce() + }) + + it('falls back to an 8-bit field when half-float color targets are unavailable', async () => { + const harness = createRippleHarness('balanced', vi.fn().mockResolvedValue(undefined), false) + const dynamics = await harness.create() + + expect(FakeRenderTarget.instances.every(target => target.options.type === 1004)).toBe(true) + dynamics.dispose() + }) + + it('clears flow-zero feedback on the next frame and then stops all GPU work', async () => { + const harness = createRippleHarness() + const dynamics = await harness.create() + dynamics.setParameters(50, 0) + dynamics.inject({ + direction: { x: 0.8, y: 0.2 }, + point: { x: 0.35, y: 0.65 }, + speed: 0.7, + timestamp: 100, + }) + harness.snapshots.length = 0 + + expect(dynamics.step(116.667)).toBe(true) + expect(harness.snapshots).toHaveLength(1) + expect(dynamics.texture).not.toBeNull() + expect(dynamics.step(133.334)).toBe(false) + expect(harness.snapshots).toHaveLength(3) + expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true) + expect(dynamics.texture).toBeNull() + + expect(dynamics.step(150)).toBe(false) + expect(harness.snapshots).toHaveLength(3) + dynamics.dispose() + }) + + it('caps propagation at two substeps while applying decay over the full elapsed time', async () => { + const harness = createRippleHarness() + const dynamics = await harness.create() + dynamics.setParameters(50, 50) + dynamics.inject({ + direction: { x: 1, y: 0 }, + point: { x: 0.5, y: 0.5 }, + speed: 0.5, + timestamp: 100, + }) + dynamics.step(116.667) + harness.snapshots.length = 0 + + expect(dynamics.step(166.667)).toBe(true) + + expect(harness.snapshots).toHaveLength(2) + const velocityHalfLife = 145 + const expectedSubstepDecay = 2 ** (-25 / velocityHalfLife) + expect(harness.snapshots.every(snapshot => snapshot.step === 1)).toBe(true) + expect( + harness.snapshots.every(snapshot => Math.abs(snapshot.velocityDecay - expectedSubstepDecay) < 0.000001), + ).toBe(true) + dynamics.dispose() + }) + + it('integrates directions while retaining the latest point and maximum impulse within one frame', async () => { + const harness = createRippleHarness() + const dynamics = await harness.create() + harness.snapshots.length = 0 + + dynamics.inject({ + direction: { x: 1, y: 0 }, + point: { x: 0.25, y: 0.35 }, + speed: 1, + timestamp: 100, + }) + dynamics.inject({ + direction: { x: 0, y: 1 }, + point: { x: 0.7, y: 0.8 }, + speed: 0.2, + timestamp: 104, + }) + + expect(dynamics.step(116.667)).toBe(true) + expect(harness.snapshots).toHaveLength(1) + expect(harness.snapshots[0].direction.x).toBeCloseTo(Math.SQRT1_2) + expect(harness.snapshots[0].direction.y).toBeCloseTo(Math.SQRT1_2) + expect(harness.snapshots[0].impulseCenter).toEqual({ x: 0.7, y: 0.8 }) + expect(harness.snapshots[0].impulse).toBeCloseTo(0.8) + expect(harness.snapshots[0].impulseOffset).toBe(28) + expect(harness.snapshots[0].impulseSigma).toBeCloseTo(75.6) + expect(harness.snapshots[0].impulseSpeed).toBe(1) + dynamics.dispose() + }) + + it('keeps the ripple footprint stable across quality levels', async () => { + const balancedHarness = createRippleHarness('balanced') + const balancedDynamics = await balancedHarness.create() + const highHarness = createRippleHarness('high') + const highDynamics = await highHarness.create() + const interaction = { + direction: { x: 1, y: 0 }, + point: { x: 0.5, y: 0.5 }, + speed: 0.5, + timestamp: 100, + } + balancedHarness.snapshots.length = 0 + highHarness.snapshots.length = 0 + + balancedDynamics.setParameters(75, 50) + highDynamics.setParameters(75, 50) + balancedDynamics.inject(interaction) + highDynamics.inject(interaction) + balancedDynamics.step(116.667) + highDynamics.step(116.667) + + expect(balancedHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4) + expect(highHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4) + balancedDynamics.dispose() + highDynamics.dispose() + }) + + it('disposes partially created resources when shader compilation fails', async () => { + const compileAsync = vi.fn().mockRejectedValue(new Error('compile failed')) + const harness = createRippleHarness('balanced', compileAsync) + + await expect(harness.create()).rejects.toThrow('compile failed') + expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true) + expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/rendering/glass/glassFluidDynamics.ts b/src/rendering/glass/glassFluidDynamics.ts new file mode 100644 index 00000000..a900db44 --- /dev/null +++ b/src/rendering/glass/glassFluidDynamics.ts @@ -0,0 +1,335 @@ +import type { + BufferGeometry, + IUniform, + OrthographicCamera, + Texture, + Vector2, + WebGLRenderer, + WebGLRenderTarget, +} from 'three' + +type ThreeModule = typeof import('three') + +interface GlassFluidFieldUniforms extends Record { + uDecay: IUniform + uInjection: IUniform + uPointer: IUniform + uPrevious: IUniform + uTexelSize: IUniform + uVelocity: IUniform + uViewportAspect: IUniform +} + +interface CreateGlassFluidDynamicsOptions { + camera: OrthographicCamera + geometry: BufferGeometry + pointer: Vector2 + renderer: WebGLRenderer + three: ThreeModule + velocity: Vector2 +} + +export interface GlassFluidDynamics { + /** 清除当前输入包络;下一帧会把时序场收敛到中性值。 */ + clearInput(): void + /** 释放 fluid 私有 shader 和两个 ping-pong target。 */ + dispose(): void + /** 清除当前帧注入,避免非输入绘制重复写入同一能量。 */ + finishFrame(): void + /** 调整 fluid field;尺寸只来自主 renderer 已提交的 buffer。 */ + resize(bufferWidth: number, bufferHeight: number, viewportWidth: number, viewportHeight: number): void + /** 更新当前帧的衰减与注入参数,不自行调度动画。 */ + setFrameParameters(decay: number, injection: number): void + /** 推进一次 field 并返回主材质应采样的最新纹理。 */ + step(): Texture +} + +export const GLASS_FLUID_DYNAMIC_RANGE_SCALE = 0.4 +export const GLASS_FLUID_DYNAMIC_RANGE_DENSITY = 1 / GLASS_FLUID_DYNAMIC_RANGE_SCALE ** 2 +const GLASS_FLUID_BUFFER_SCALE = 0.25 + +const GLASS_FLUID_VERTEX_SHADER = ` +varying vec2 vUv; + +void main() { + vUv = position.xy * 0.5 + 0.5; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +` + +export const GLASS_FLUID_FIELD_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 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)}) * 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 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)}); + + 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); +} +` + +/** fluid 主材质的全局临时量;由共享 shader 在原位置逐字拼装。 */ +export const GLASS_FLUID_FRAGMENT_SETUP = ` 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; + float trailSpatialSpan = 0.0; + float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion); + const float dynamicRangeScale = ${GLASS_FLUID_DYNAMIC_RANGE_SCALE.toFixed(2)}; + const float dynamicRangeDensity = ${GLASS_FLUID_DYNAMIC_RANGE_DENSITY.toFixed(3)};` + +/** fluid 的 trail 与高质量 temporal field 响应。 */ +export const GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD = ` 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) * motionRangeCompression; + vec2 trailSpanDelta = trail.xy - uPointer; + trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; + trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z); + float along = dot(trailDelta, wakeDirection); + float across = dot(trailDelta, wakePerpendicular); + float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity; + float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion) * dynamicRangeDensity; + 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 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; + }` + +/** 单个 surface 内的 fluid 指针、方向、wake 与能量形态。 */ +export const GLASS_FLUID_FRAGMENT_SURFACE_SHAPE = ` vec2 pointerDelta = uPointer - vUv; + vec2 pointerDeltaAspect = pointerDelta; + pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; + // 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。 + float pointerSpread = mix(26.0, 17.0, uQuality); + pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion); + float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan); + float pointerAlong = dot(-pointerDeltaAspect, wakeDirection); + float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular); + float sharedWakeTravel = + 0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion); + float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread); + float directionalPointerShape = + exp(-( + pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 + + pointerAcross * pointerAcross * pointerSpread * 1.35 + )); + float pointerEnergy = + clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0); + float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion); + float radialSharedWave = + exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity); + float directionalSharedWave = + exp(-( + pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 + + pointerAcross * pointerAcross * sharedWaveDensity * 2.2 + )); + float sharedWaveEnergy = + mix(radialSharedWave, directionalSharedWave, sharedDirectionality) * + clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) * + mix(1.0, 0.78, sharedDirectionality) * + uMotion * + uMotion; + vec2 wakeDelta = vUv - uPointer; + wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; + float wakeAlong = dot(wakeDelta, wakeDirection); + float wakeAcross = dot(wakeDelta, wakePerpendicular); + float wakeTravel = + 0.014 * dynamicRangeScale * + mix(0.82, 1.18, uQuality) * + mix(1.0, 1.45, uMotionExpansion); + float wakeWidth = + mix(0.027, 0.044, uQuality) * dynamicRangeScale * 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) * + dynamicRangeDensity * + 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, wakeEnergy * 0.82) + ), 0.0, 1.0);` + +/** 单个 surface 内的 fluid 高光与焦散响应。 */ +export const GLASS_FLUID_FRAGMENT_SURFACE_OPTICS = ` 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) * dynamicRangeScale); + specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression; + float specularAlong = dot(specularDelta, wakeDirection); + float specularAcross = dot(specularDelta, wakePerpendicular); + float singleSpecular = + exp(-( + specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) * dynamicRangeDensity + + specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) * dynamicRangeDensity + )) * + uMotion * + mix(1.0, 1.24, uMotionExpansion); + float localCaustic = singleSpecular * rectMask * surfaceDynamic * interactionMask;` + +/** fluid 对共享 dynamicRefraction 的贡献;静态透镜和 ripple 响应仍由主材质合成。 */ +export const GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION = ` vec2 sampleTranslation = + uPointerVelocity * + mix(0.055, 0.075, uQuality) * + uMotion * + uTranslationStrength; + dynamicRefraction += ( + sampleTranslation + + // 收紧高斯半径时补偿向量峰值,避免范围缩小同时削弱用户设置的形变强度。 + pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength / dynamicRangeScale + + trailRefraction * trailStrength + + temporalFlow * temporalStrength + + wakeRefraction + ) * rectMask * surfaceDynamic * interactionMask;` + +/** 创建仅由高质量 fluid 模式持有的时序位移场。 */ +export function createGlassFluidDynamics(options: CreateGlassFluidDynamicsOptions): GlassFluidDynamics { + const { camera, geometry, pointer, renderer, three, velocity } = options + let disposed = false + const createTarget = () => + new three.WebGLRenderTarget(1, 1, { + depthBuffer: false, + magFilter: three.LinearFilter, + minFilter: three.LinearFilter, + stencilBuffer: false, + }) + let readTarget: WebGLRenderTarget = createTarget() + let writeTarget: WebGLRenderTarget = createTarget() + const uniforms: GlassFluidFieldUniforms = { + uDecay: { value: 1 }, + uInjection: { value: 0 }, + uPointer: { value: pointer }, + uPrevious: { value: null }, + uTexelSize: { value: new three.Vector2(1, 1) }, + uVelocity: { value: velocity }, + uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) }, + } + const material = new three.ShaderMaterial({ + depthTest: false, + depthWrite: false, + fragmentShader: GLASS_FLUID_FIELD_FRAGMENT_SHADER, + uniforms, + vertexShader: GLASS_FLUID_VERTEX_SHADER, + }) + const scene = new three.Scene() + const mesh = new three.Mesh(geometry, material) + mesh.frustumCulled = false + scene.add(mesh) + + return { + clearInput() { + uniforms.uDecay.value = 0 + uniforms.uInjection.value = 0 + }, + dispose() { + if (disposed) return + disposed = true + material.dispose() + readTarget.dispose() + writeTarget.dispose() + }, + finishFrame() { + uniforms.uInjection.value = 0 + }, + resize(bufferWidth, bufferHeight, viewportWidth, viewportHeight) { + if (disposed) return + const width = Math.max(96, Math.round(bufferWidth * GLASS_FLUID_BUFFER_SCALE)) + const height = Math.max(96, Math.round(bufferHeight * GLASS_FLUID_BUFFER_SCALE)) + if (readTarget.width !== width || readTarget.height !== height) { + readTarget.setSize(width, height) + writeTarget.setSize(width, height) + } + uniforms.uTexelSize.value.set(1 / width, 1 / height) + uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1) + }, + setFrameParameters(decay, injection) { + uniforms.uDecay.value = decay + uniforms.uInjection.value = injection + }, + step() { + if (disposed) return readTarget.texture + renderer.setScissorTest(false) + uniforms.uPrevious.value = readTarget.texture + renderer.setRenderTarget(writeTarget) + renderer.render(scene, camera) + renderer.setRenderTarget(null) + const previousReadTarget = readTarget + readTarget = writeTarget + writeTarget = previousReadTarget + + return readTarget.texture + }, + } +} diff --git a/src/rendering/glass/glassRippleDynamics.ts b/src/rendering/glass/glassRippleDynamics.ts new file mode 100644 index 00000000..0b5f28f9 --- /dev/null +++ b/src/rendering/glass/glassRippleDynamics.ts @@ -0,0 +1,480 @@ +import type { + BufferGeometry, + IUniform, + OrthographicCamera, + Texture, + Vector2, + WebGLRenderer, + WebGLRenderTarget, +} from 'three' +import type { GlassOpticalQuality } from '@/utils/glassOptics' + +type ThreeModule = typeof import('three') + +export type GlassRippleQuality = Exclude + +interface GlassRippleUniforms extends Record { + uEnergyDecay: IUniform + uHeightDecay: IUniform + uImpulse: IUniform + uImpulseCenter: IUniform + uImpulseDirection: IUniform + uImpulseOffset: IUniform + uImpulseSigma: IUniform + uImpulseSpeed: IUniform + uPrevious: IUniform + uPropagation: IUniform + uQuality: IUniform + uReset: IUniform + uRestoring: IUniform + uStep: IUniform + uTexelSize: IUniform + uVelocityDecay: IUniform + uViewportSize: IUniform +} + +export interface GlassRippleInteraction { + /** CSS viewport 中归一化后的输入位置,Y 轴以 WebGL 底部为原点。 */ + point: { x: number; y: number } + /** 归一化后的指针移动方向。 */ + direction: { x: number; y: number } + /** 现有 renderer 归一化后的速度强度,范围 0 到 1。 */ + speed: number + /** 与 performance timeline 一致的事件时间。 */ + timestamp: number +} + +export interface GlassRippleDynamics { + /** 释放波场及其 GPU 资源。 */ + dispose(): void + /** 将输入合并到下一次 GPU step,不为每个事件立即绘制。 */ + inject(interaction: GlassRippleInteraction): void + /** 当前可供主材质采样的波场纹理;空场返回 null。 */ + readonly texture: Texture | null + /** 当前波场单 texel 的 UV 尺寸,供主材质计算高度梯度。 */ + readonly texelSize: Vector2 + /** 更新共享动态参数,不重建 GPU 资源。 */ + setParameters(translationStrength: number, flowStrength: number): void + /** 调整 viewport-space 波场;尺寸变化会恢复为空场。 */ + resize(viewportWidth: number, viewportHeight: number): void + /** 立即清空两个 ping-pong target,并停止 CPU 生命周期。 */ + reset(): void + /** 推进一步波场;返回 false 表示已提交清场并停止。 */ + step(timestamp: number): boolean +} + +interface CreateGlassRippleDynamicsOptions { + camera: OrthographicCamera + geometry: BufferGeometry + quality: GlassRippleQuality + renderer: WebGLRenderer + three: ThreeModule + viewportHeight: number + viewportWidth: number +} + +const RIPPLE_VERTEX_SHADER = ` +varying vec2 vUv; + +void main() { + vUv = position.xy * 0.5 + 0.5; + gl_Position = vec4(position.xy, 0.0, 1.0); +} +` + +export const RIPPLE_FRAGMENT_SHADER = ` +precision highp float; + +uniform sampler2D uPrevious; +uniform vec2 uTexelSize; +uniform vec2 uViewportSize; +uniform vec2 uImpulseCenter; +uniform vec2 uImpulseDirection; +uniform float uImpulse; +uniform float uImpulseOffset; +uniform float uImpulseSigma; +uniform float uImpulseSpeed; +uniform float uPropagation; +uniform float uRestoring; +uniform float uVelocityDecay; +uniform float uHeightDecay; +uniform float uEnergyDecay; +uniform float uStep; +uniform float uQuality; +uniform float uReset; +varying vec2 vUv; + +vec3 decodeState(vec4 sampleValue) { + if (sampleValue.b < (1.0 / 255.0)) return vec3(0.0); + + return vec3(sampleValue.rg * 2.0 - 1.0, sampleValue.b); +} + +float sampleHeight(vec2 offset) { + return decodeState(texture2D(uPrevious, clamp(vUv + offset, vec2(0.0), vec2(1.0)))).x; +} + +void main() { + if (uReset > 0.5) { + gl_FragColor = vec4(0.5, 0.5, 0.0, 1.0); + return; + } + + vec3 previous = decodeState(texture2D(uPrevious, vUv)); + float h = previous.x; + float velocity = previous.y; + float energy = previous.z; + float cardinal1 = ( + sampleHeight(vec2(uTexelSize.x, 0.0)) + + sampleHeight(vec2(-uTexelSize.x, 0.0)) + + sampleHeight(vec2(0.0, uTexelSize.y)) + + sampleHeight(vec2(0.0, -uTexelSize.y)) + ) * 0.25; + float cardinal2 = ( + sampleHeight(vec2(uTexelSize.x * 2.0, 0.0)) + + sampleHeight(vec2(-uTexelSize.x * 2.0, 0.0)) + + sampleHeight(vec2(0.0, uTexelSize.y * 2.0)) + + sampleHeight(vec2(0.0, -uTexelSize.y * 2.0)) + ) * 0.25; + float diagonal1 = ( + sampleHeight(vec2(uTexelSize.x, uTexelSize.y)) + + sampleHeight(vec2(-uTexelSize.x, uTexelSize.y)) + + sampleHeight(vec2(uTexelSize.x, -uTexelSize.y)) + + sampleHeight(vec2(-uTexelSize.x, -uTexelSize.y)) + ) * 0.25; + float diagonal2 = ( + sampleHeight(vec2(uTexelSize.x * 2.0, uTexelSize.y * 2.0)) + + sampleHeight(vec2(-uTexelSize.x * 2.0, uTexelSize.y * 2.0)) + + sampleHeight(vec2(uTexelSize.x * 2.0, -uTexelSize.y * 2.0)) + + sampleHeight(vec2(-uTexelSize.x * 2.0, -uTexelSize.y * 2.0)) + ) * 0.25; + float balancedMean = cardinal1 * 0.72 + cardinal2 * 0.28; + float highMean = cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12; + float curvature = mix(balancedMean, highMean, uQuality) - h; + + float speedResponse = smoothstep(0.0, 1.0, uImpulseSpeed); + vec2 shiftedCenter = uImpulseCenter + + uImpulseDirection * uImpulseOffset * mix(0.35, 1.0, speedResponse) / max(uViewportSize, vec2(1.0)); + vec2 impulseDelta = (vUv - shiftedCenter) * uViewportSize; + float directionLength = length(uImpulseDirection); + vec2 flowDirection = directionLength > 0.0001 ? uImpulseDirection / directionLength : vec2(0.0, 1.0); + vec2 flowPerpendicular = vec2(-flowDirection.y, flowDirection.x); + float along = dot(impulseDelta, flowDirection); + float across = dot(impulseDelta, flowPerpendicular); + float directionalRadius = length(vec2(along * 0.72, across * 1.24)); + float directionality = step(0.0001, directionLength) * mix(0.32, 0.72, speedResponse); + float radius = mix(length(impulseDelta), directionalRadius, directionality); + float sigma = max(uImpulseSigma * mix(0.86, 1.05, speedResponse), 1.0); + float normalizedRadius = radius / sigma; + float core = exp(-0.5 * pow(normalizedRadius, 2.0)); + float ring = exp(-0.5 * pow((radius - 1.6 * sigma) / (0.55 * sigma), 2.0)); + float centerRelease = smoothstep(0.0, 0.55, normalizedRadius); + float annularCore = normalizedRadius * core; + float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse; + float wakeEnvelope = exp(-0.5 * ( + pow(along / (1.25 * sigma), 2.0) + + pow(across / (0.72 * sigma), 2.0) + )); + float directionalImpulse = clamp((-along / sigma) * wakeEnvelope * uImpulse * 0.9, -0.62, 0.62); + float impulse = clamp( + mix(radialImpulse, directionalImpulse, directionality), + -0.62, + 0.62 + ); + + velocity = clamp( + ( + velocity + + curvature * uPropagation * uStep - + h * uRestoring * uStep + + impulse * mix(0.52, 0.82, speedResponse) + ) * uVelocityDecay, + -1.0, + 1.0 + ); + h = clamp((h + velocity * uStep) * uHeightDecay, -1.0, 1.0); + energy = clamp(max(max(energy * uEnergyDecay, abs(h)), abs(impulse)), 0.0, 1.0); + gl_FragColor = vec4(h * 0.5 + 0.5, velocity * 0.5 + 0.5, energy, 1.0); +} +` + +const FRESHNESS_MS = 40 +const ENVELOPE_THRESHOLD = 0.006 +const MAX_STEP_MS = 16.667 +const MIN_STEP_MS = 4 + +function clamp01(value: number) { + return Math.min(1, Math.max(0, value)) +} + +function mix(start: number, end: number, progress: number) { + return start + (end - start) * progress +} + +/** 创建仅由单个 renderer context 持有的 viewport-space 水漾场。 */ +export async function createGlassRippleDynamics( + options: CreateGlassRippleDynamicsOptions, +): Promise { + const { camera, geometry, renderer, three } = options + const quality = options.quality + let viewportWidth = Math.max(1, options.viewportWidth) + let viewportHeight = Math.max(1, options.viewportHeight) + let translation = 0.5 + let flow = 0.5 + let energyAtInput = 0 + let lastInputAt = Number.NEGATIVE_INFINITY + let deadlineAt = Number.NEGATIVE_INFINITY + let lastStepAt = 0 + let pendingImpulse = 0 + let pendingSpeed = 0 + let impulseCenter = { x: 0.5, y: 0.5 } + let impulseDirection = { x: 0, y: 1 } + let pendingDirection = { x: 0, y: 0 } + let clearOnNextFrame = false + let fieldActive = false + let disposed = false + const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType + + const createTarget = () => { + const target = new three.WebGLRenderTarget(1, 1, { + depthBuffer: false, + format: three.RGBAFormat, + magFilter: three.LinearFilter, + minFilter: three.LinearFilter, + stencilBuffer: false, + type: targetType, + wrapS: three.ClampToEdgeWrapping, + wrapT: three.ClampToEdgeWrapping, + }) + target.texture.generateMipmaps = false + + return target + } + let readTarget = createTarget() + let writeTarget = createTarget() + const uniforms: GlassRippleUniforms = { + uEnergyDecay: { value: 1 }, + uHeightDecay: { value: 1 }, + uImpulse: { value: 0 }, + uImpulseCenter: { value: new three.Vector2(0.5, 0.5) }, + uImpulseDirection: { value: new three.Vector2(0, 1) }, + uImpulseOffset: { value: 0 }, + uImpulseSigma: { value: 24 }, + uImpulseSpeed: { value: 0 }, + uPrevious: { value: null }, + uPropagation: { value: 0.18 }, + uQuality: { value: quality === 'high' ? 1 : 0 }, + uReset: { value: 1 }, + uRestoring: { value: quality === 'high' ? 0.028 : 0.035 }, + uStep: { value: 1 }, + uTexelSize: { value: new three.Vector2(1, 1) }, + uVelocityDecay: { value: 1 }, + uViewportSize: { value: new three.Vector2(viewportWidth, viewportHeight) }, + } + const material = new three.ShaderMaterial({ + depthTest: false, + depthWrite: false, + fragmentShader: RIPPLE_FRAGMENT_SHADER, + uniforms, + vertexShader: RIPPLE_VERTEX_SHADER, + }) + const scene = new three.Scene() + const mesh = new three.Mesh(geometry, material) + mesh.frustumCulled = false + scene.add(mesh) + + const renderTarget = (target: WebGLRenderTarget) => { + const previousTarget = renderer.getRenderTarget() + + try { + renderer.setScissorTest(false) + renderer.setRenderTarget(target) + renderer.render(scene, camera) + } finally { + renderer.setRenderTarget(previousTarget) + } + } + + const writeNeutralTargets = () => { + uniforms.uReset.value = 1 + uniforms.uPrevious.value = null + renderTarget(readTarget) + renderTarget(writeTarget) + uniforms.uReset.value = 0 + } + + const getTargetSize = (width: number, height: number) => { + const scale = + quality === 'high' + ? Math.min(1, Math.max(0.25, 192 / width, 128 / height)) + : Math.min(1, Math.max(0.16, 128 / width, 96 / height)) + + return { + height: Math.max(1, Math.round(height * scale)), + width: Math.max(1, Math.round(width * scale)), + } + } + + const resize = (width: number, height: number) => { + if (disposed) return false + const nextViewportWidth = Math.max(1, width) + const nextViewportHeight = Math.max(1, height) + const target = getTargetSize(nextViewportWidth, nextViewportHeight) + const viewportChanged = viewportWidth !== nextViewportWidth || viewportHeight !== nextViewportHeight + const targetChanged = readTarget.width !== target.width || readTarget.height !== target.height + if (!viewportChanged && !targetChanged) return false + + viewportWidth = nextViewportWidth + viewportHeight = nextViewportHeight + if (targetChanged) { + readTarget.setSize(target.width, target.height) + writeTarget.setSize(target.width, target.height) + } + uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height) + uniforms.uViewportSize.value.set(viewportWidth, viewportHeight) + reset() + + return true + } + + const getVelocityHalfLife = () => (quality === 'high' ? mix(90, 280, flow) : mix(70, 220, flow)) + + const getDeadlineDuration = () => FRESHNESS_MS + (quality === 'high' ? mix(220, 920, flow) : mix(160, 680, flow)) + + const settleEnvelope = (timestamp: number) => { + if (!Number.isFinite(lastInputAt)) return 0 + const freshReleaseAge = Math.max(0, timestamp - lastInputAt - FRESHNESS_MS) + const deadlineTaper = clamp01((deadlineAt - timestamp) / FRESHNESS_MS) + + return energyAtInput * 2 ** (-freshReleaseAge / getVelocityHalfLife()) * deadlineTaper + } + + const reset = () => { + if (disposed) return + writeNeutralTargets() + energyAtInput = 0 + lastInputAt = Number.NEGATIVE_INFINITY + deadlineAt = Number.NEGATIVE_INFINITY + lastStepAt = 0 + pendingImpulse = 0 + pendingSpeed = 0 + pendingDirection = { x: 0, y: 0 } + impulseDirection = { x: 0, y: 1 } + clearOnNextFrame = false + fieldActive = false + } + + try { + const initializedByResize = resize(viewportWidth, viewportHeight) + await renderer.compileAsync(scene, camera) + if (disposed) throw new Error('Ripple resources were disposed during compilation') + if (!initializedByResize) reset() + } catch (error) { + material.dispose() + readTarget.dispose() + writeTarget.dispose() + throw error + } + + return { + dispose() { + if (disposed) return + disposed = true + material.dispose() + readTarget.dispose() + writeTarget.dispose() + }, + inject(interaction) { + if (disposed) return + const timestamp = interaction.timestamp + const previousEnvelope = settleEnvelope(timestamp) + const inputAmplitude = Math.min(0.8, Math.max(0.22, 0.22 + clamp01(interaction.speed) * 0.58)) + energyAtInput = Math.max(previousEnvelope, inputAmplitude) + lastInputAt = timestamp + deadlineAt = timestamp + getDeadlineDuration() + pendingImpulse = Math.max(pendingImpulse, inputAmplitude) + pendingSpeed = Math.max(pendingSpeed, clamp01(interaction.speed)) + impulseCenter = { x: clamp01(interaction.point.x), y: clamp01(interaction.point.y) } + const directionLength = Math.hypot(interaction.direction.x, interaction.direction.y) + if (directionLength > 0.0001) { + pendingDirection.x += interaction.direction.x / directionLength + pendingDirection.y += interaction.direction.y / directionLength + } + fieldActive = true + clearOnNextFrame = false + }, + get texture() { + return fieldActive && !disposed ? readTarget.texture : null + }, + get texelSize() { + return uniforms.uTexelSize.value + }, + setParameters(translationStrength, flowStrength) { + translation = clamp01(translationStrength / 100) + flow = clamp01(flowStrength / 100) + }, + resize, + reset, + step(timestamp) { + if (disposed || !fieldActive) return false + if (clearOnNextFrame || timestamp >= deadlineAt || settleEnvelope(timestamp) < ENVELOPE_THRESHOLD) { + reset() + return false + } + + const elapsed = lastStepAt > 0 ? Math.max(0, timestamp - lastStepAt) : MAX_STEP_MS + const simulatedElapsed = Math.min(MAX_STEP_MS * 2, Math.max(MIN_STEP_MS, elapsed)) + const substeps = simulatedElapsed > MAX_STEP_MS ? 2 : 1 + const stepMs = Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, simulatedElapsed / substeps)) + const decayStepMs = elapsed / substeps + const velocityHalfLife = getVelocityHalfLife() + const heightHalfLife = velocityHalfLife * 0.82 + const energyHalfLife = velocityHalfLife * 0.72 + const targetCssPerTexel = Math.sqrt( + (viewportWidth / Math.max(readTarget.width, 1)) * (viewportHeight / Math.max(readTarget.height, 1)), + ) + const referenceCssPerTexel = quality === 'high' ? 4 : 6.25 + const basePropagation = quality === 'high' ? mix(0.11, 0.16, translation) : mix(0.12, 0.18, translation) + + if (pendingImpulse > 0) { + const directionLength = Math.hypot(pendingDirection.x, pendingDirection.y) + impulseDirection = + directionLength > 0.0001 + ? { x: pendingDirection.x / directionLength, y: pendingDirection.y / directionLength } + : { x: 0, y: 0 } + } + + uniforms.uImpulseCenter.value.set(impulseCenter.x, impulseCenter.y) + uniforms.uImpulseDirection.value.set(impulseDirection.x, impulseDirection.y) + uniforms.uImpulseOffset.value = 56 * translation + uniforms.uImpulseSpeed.value = pendingSpeed + // 质量档把额外预算用于场分辨率和衰减细节;输入范围保持稳定,避免高质量改变动态效果的空间语义。 + uniforms.uImpulseSigma.value = mix(54, 97.2, translation) + uniforms.uPropagation.value = Math.min( + 0.18, + Math.max(0.08, basePropagation * (referenceCssPerTexel / targetCssPerTexel) ** 2), + ) + uniforms.uRestoring.value = quality === 'high' ? 0.028 : 0.035 + uniforms.uStep.value = stepMs / MAX_STEP_MS + uniforms.uVelocityDecay.value = 2 ** (-decayStepMs / velocityHalfLife) + uniforms.uHeightDecay.value = 2 ** (-decayStepMs / heightHalfLife) + uniforms.uEnergyDecay.value = 2 ** (-decayStepMs / energyHalfLife) + + for (let index = 0; index < substeps; index += 1) { + uniforms.uPrevious.value = readTarget.texture + uniforms.uImpulse.value = index === 0 ? pendingImpulse : 0 + renderTarget(writeTarget) + const previousReadTarget = readTarget + readTarget = writeTarget + writeTarget = previousReadTarget + } + pendingImpulse = 0 + pendingSpeed = 0 + pendingDirection = { x: 0, y: 0 } + lastStepAt = timestamp + if (flow <= 0) clearOnNextFrame = true + + return true + }, + } +}