feat(glass): add selectable dynamics modes (#639)

This commit is contained in:
InfinityPacer
2026-08-04 16:34:39 +08:00
committed by GitHub
parent 084b950fec
commit a545f88b3c
17 changed files with 3073 additions and 379 deletions

View File

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

View File

@@ -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<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
const draftDynamicsMode = ref<ThemeCustomizerGlassDynamicsMode>(settings.value.glassDynamicsMode)
const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...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<GlassOpticalPreset>(() =>
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) }}
</VBtn>
</VBtnToggle>
<p class="glass-settings-dialog__hint">{{ t('theme.glassAppearanceHint') }}</p>
</section>
<section>
@@ -362,6 +390,29 @@ onScopeDispose(cancelGlassPreview)
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
<p class="glass-settings-dialog__hint">{{ t('theme.glassPresetHint') }}</p>
</section>
<section v-if="showsDynamicsMode" class="glass-settings-dialog__dynamics-mode-section">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDynamicsMode') }}</h3>
<VBtnToggle
:model-value="draftDynamicsMode"
mandatory
color="primary"
variant="text"
class="glass-settings-dialog__dynamics-mode"
@update:model-value="updateDynamicsMode"
>
<VBtn
v-for="option in dynamicsModeOptions"
:key="option.value"
:value="option.value"
class="glass-settings-dialog__dynamics-mode-option"
>
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
<p class="glass-settings-dialog__hint">{{ t(dynamicsModeHint) }}</p>
</section>
<section class="glass-settings-dialog__tuning">
@@ -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;

View File

@@ -8,6 +8,8 @@ const dialogStub = {
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
}
const toggleStub = {
emits: ['update:modelValue'],
name: 'VBtnToggle',
props: ['modelValue'],
template: '<div :data-model-value="modelValue"><slot /></div>',
}
@@ -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)
})
})

View File

@@ -1,5 +1,10 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { usePreferredReducedMotion } from '@vueuse/core'
import type {
ThemeCustomizerGlassAppearance,
ThemeCustomizerGlassDynamicsMode,
ThemeCustomizerGlassQuality,
} from '@/composables/useThemeCustomizer'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
@@ -18,6 +23,8 @@ const props = defineProps<{
deformationStrength: number
/** 用户选择的轨迹、尾波与惯性强度。 */
flowStrength: number
/** 用户保存的动态效果模式;能力降级不会回写该选择。 */
dynamicsMode: ThemeCustomizerGlassDynamicsMode
/** 当前光学质量;标准档不会挂载该组件。 */
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
/** 用户选择的亮边、镜面高光与焦散强度。 */
@@ -74,7 +81,16 @@ const emit = defineEmits<{
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const usesMobilePresentation = useGlassMobilePresentation()
const dynamicsActive = computed(() => !usesMobilePresentation.value)
const preferredMotion = usePreferredReducedMotion()
const compositeFailureLatched = ref(false)
const compositeRecoveryPending = ref(false)
const presentationMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
usesMobilePresentation.value || preferredMotion.value === 'reduce' ? 'off' : props.dynamicsMode,
)
const effectiveDynamicsMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
compositeFailureLatched.value ? 'off' : presentationMode.value,
)
const dynamicsActive = computed(() => effectiveDynamicsMode.value !== 'off')
const interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache()
@@ -84,6 +100,7 @@ const fixedRenderer = useGlassOpticalRenderer({
canvas: fixedCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
dynamicsMode: effectiveDynamicsMode,
flowStrength: () => props.flowStrength,
interactionSource,
quality: () => props.quality,
@@ -109,6 +126,7 @@ const scrollRenderer = useGlassOpticalRenderer({
canvas: scrollCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
dynamicsMode: effectiveDynamicsMode,
flowStrength: () => props.flowStrength,
interactionSource,
pageMotion: pagePresentationMotion.reader,
@@ -130,16 +148,45 @@ const scrollRenderer = useGlassOpticalRenderer({
syncDocumentState: false,
})
/** 用户显式改选动态策略时,用同一代次重建两个已进入复合回退的 renderer。 */
function retryCompositeRenderers() {
compositeRecoveryPending.value = true
compositeFailureLatched.value = false
void Promise.allSettled([fixedRenderer.retryAfterFailure(), scrollRenderer.retryAfterFailure()])
}
watch(
() => props.dynamicsMode,
(mode, previousMode) => {
if (mode === previousMode || (!compositeFailureLatched.value && !compositeRecoveryPending.value)) return
retryCompositeRenderers()
},
)
const rendererState = ref<GlassRendererState>('loading')
/** 两个呈现 context 作为同一材质能力接管 CSS避免部分就绪时出现混合材质。 */
watchEffect(() => {
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
const state: GlassRendererState = states.every(value => value === 'ready')
? 'ready'
: states.some(value => value === 'loading')
? 'loading'
: 'fallback'
const allReady = states.every(value => value === 'ready')
const anyFallback = states.some(value => value === 'fallback')
const anyLoading = states.some(value => value === 'loading')
if (compositeRecoveryPending.value) {
if (allReady) compositeRecoveryPending.value = false
else if (anyFallback && !anyLoading) {
compositeRecoveryPending.value = false
compositeFailureLatched.value = true
}
} else if (anyFallback) compositeFailureLatched.value = true
else if (compositeFailureLatched.value && allReady) compositeFailureLatched.value = false
const state: GlassRendererState = compositeRecoveryPending.value
? 'loading'
: compositeFailureLatched.value
? 'fallback'
: allReady
? 'ready'
: 'loading'
setGlassRendererState(rendererState, state)
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
@@ -151,6 +198,11 @@ watchEffect(() => {
}
})
watchEffect(() => {
document.documentElement.dataset.glassDynamicsMode = props.dynamicsMode
document.documentElement.dataset.glassDynamicsEffectiveMode = effectiveDynamicsMode.value
})
let lastPreparedAcknowledgement = ''
watchEffect(() => {
const url = props.pendingWallpaperUrl
@@ -287,6 +339,8 @@ watchEffect(() => {
onScopeDispose(() => {
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
delete document.documentElement.dataset.glassDynamicsMode
delete document.documentElement.dataset.glassDynamicsEffectiveMode
setGlassRendererState(rendererState, 'fallback')
})
</script>

View File

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

View File

@@ -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<Record<string, unknown>>)
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<typeof vi.fn>
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
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',

View File

@@ -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<number, FrameRequestCallback>()
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<string, { value: unknown }> } }>
},
)
.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<Object3D>(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<string, { value: unknown }> } }>
},
)
.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<never>((_, 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<Object3D>(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<Object3D>(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<number, FrameRequestCallback>()
let frameId = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frameId += 1
callbacks.set(frameId, callback)
return frameId
})
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
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()
})
})

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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<typeof useTheme>
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<ThemeCustomizerGlassSettings
glassPreviewState.value = {
glassAppearance: previewSettings.glassAppearance,
glassDeformationStrength: previewSettings.glassDeformationStrength,
glassDynamicsMode: previewSettings.glassDynamicsMode,
glassFlowStrength: previewSettings.glassFlowStrength,
glassPreset: previewSettings.glassPreset,
glassPresetOverrides: previewSettings.glassPresetOverrides,
@@ -645,6 +658,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
return (
settings.glassAppearance === defaults.glassAppearance &&
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
settings.glassDynamicsMode === defaults.glassDynamicsMode &&
settings.glassFlowStrength === defaults.glassFlowStrength &&
settings.glassPreset === defaults.glassPreset &&
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
@@ -762,6 +776,11 @@ export function useThemeCustomizer() {
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
}
/** 切换玻璃动态效果,不改写当前预设归属或六个具体参数。 */
function setGlassDynamicsMode(glassDynamicsMode: ThemeCustomizerGlassDynamicsMode) {
return updateSettings({ glassDynamicsMode })
}
/** 更新玻璃轨迹、尾波与惯性强度。 */
function setGlassFlowStrength(glassFlowStrength: number) {
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
@@ -901,6 +920,7 @@ export function useThemeCustomizer() {
resetSettings,
setGlassAppearance,
setGlassDeformationStrength,
setGlassDynamicsMode,
setGlassFlowStrength,
setGlassPreset,
setGlassQuality,

View File

@@ -152,6 +152,8 @@ export default {
glassAppearanceClear: 'Clear',
glassAppearanceTinted: 'Tinted',
glassAppearanceFrosted: 'Frosted',
glassAppearanceHint:
'Clear emphasizes wallpaper detail, Tinted adds color coverage, and Frosted uses blur diffusion for a denser glass surface.',
glassQuality: 'Quality',
glassQualityCss: 'Standard',
glassQualityBalanced: 'Balanced',
@@ -165,6 +167,14 @@ export default {
glassPresetNatural: 'Natural',
glassPresetGlide: 'Glide',
glassPresetLiquid: 'Liquid',
glassPresetHint: 'Natural stays balanced, Glide favors smooth movement, and Liquid adds deformation and inertia.',
glassDynamicsMode: 'Motion Effect',
glassDynamicsModeFluid: 'Fluid',
glassDynamicsModeRipple: 'Ripple',
glassDynamicsModeOff: 'Off',
glassDynamicsModeFluidHint: 'Creates continuous flow and refraction that follow the pointer.',
glassDynamicsModeRippleHint: 'Creates ripples that spread across nearby glass surfaces as the pointer moves.',
glassDynamicsModeOffHint: 'Keeps the static material without pointer-driven motion.',
glassMaterialTuning: 'Material',
glassDynamicTuning: 'Motion',
glassTranslationStrength: 'Sample Translation',

View File

@@ -150,6 +150,7 @@ export default {
glassAppearanceClear: '透明',
glassAppearanceTinted: '色调',
glassAppearanceFrosted: '磨砂',
glassAppearanceHint: '透明突出壁纸纹理,色调增加颜色覆盖,磨砂通过模糊扩散呈现更厚的玻璃质感。',
glassQuality: '质量',
glassQualityCss: '标准',
glassQualityBalanced: '均衡',
@@ -162,6 +163,14 @@ export default {
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液态',
glassPresetHint: '自然均衡克制,滑移强调顺畅移动,液态增强形变与惯性。',
glassDynamicsMode: '动态效果',
glassDynamicsModeFluid: '流体',
glassDynamicsModeRipple: '水漾',
glassDynamicsModeOff: '关闭',
glassDynamicsModeFluidHint: '跟随指针形成连续的流动与折射反馈。',
glassDynamicsModeRippleHint: '指针经过玻璃时产生向相邻表面扩散的水纹。',
glassDynamicsModeOffHint: '保留静态材质,不响应指针动态。',
glassMaterialTuning: '材质参数',
glassDynamicTuning: '动态参数',
glassTranslationStrength: '采样平移',

View File

@@ -150,6 +150,7 @@ export default {
glassAppearanceClear: '透明',
glassAppearanceTinted: '色調',
glassAppearanceFrosted: '磨砂',
glassAppearanceHint: '透明強調桌布紋理,色調增加色彩覆蓋,磨砂透過模糊擴散呈現更厚實的玻璃質感。',
glassQuality: '品質',
glassQualityCss: '標準',
glassQualityBalanced: '均衡',
@@ -162,6 +163,14 @@ export default {
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液態',
glassPresetHint: '自然均衡克制,滑移強調順暢移動,液態增強形變與慣性。',
glassDynamicsMode: '動態效果',
glassDynamicsModeFluid: '流體',
glassDynamicsModeRipple: '水漾',
glassDynamicsModeOff: '關閉',
glassDynamicsModeFluidHint: '跟隨指標形成連續的流動與折射回饋。',
glassDynamicsModeRippleHint: '指標經過玻璃時產生向相鄰表面擴散的水紋。',
glassDynamicsModeOffHint: '保留靜態材質,不回應指標動態。',
glassMaterialTuning: '材質參數',
glassDynamicTuning: '動態參數',
glassTranslationStrength: '採樣平移',

View File

@@ -0,0 +1,187 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGlassFluidDynamics, GLASS_FLUID_FIELD_FRAGMENT_SHADER } from '@/rendering/glass/glassFluidDynamics'
class FakeVector2 {
constructor(
public x = 0,
public y = 0,
) {}
set(x: number, y: number) {
this.x = x
this.y = y
return this
}
}
class FakeRenderTarget {
static instances: FakeRenderTarget[] = []
readonly dispose = vi.fn()
height: number
readonly setSize = vi.fn((width: number, height: number) => {
this.width = width
this.height = height
})
readonly texture = {}
width: number
constructor(
width: number,
height: number,
readonly options: Record<string, unknown>,
) {
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<string, { value: unknown }>
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
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')
})
})

View File

@@ -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<string, unknown>
width = 1
constructor(
_width: number,
_height: number,
readonly options: Record<string, unknown>,
) {
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<string, { value: unknown }>
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
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()
})
})

View File

@@ -0,0 +1,335 @@
import type {
BufferGeometry,
IUniform,
OrthographicCamera,
Texture,
Vector2,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
type ThreeModule = typeof import('three')
interface GlassFluidFieldUniforms extends Record<string, IUniform> {
uDecay: IUniform<number>
uInjection: IUniform<number>
uPointer: IUniform<Vector2>
uPrevious: IUniform<Texture | null>
uTexelSize: IUniform<Vector2>
uVelocity: IUniform<Vector2>
uViewportAspect: IUniform<number>
}
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
},
}
}

View File

@@ -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<GlassOpticalQuality, 'css'>
interface GlassRippleUniforms extends Record<string, IUniform> {
uEnergyDecay: IUniform<number>
uHeightDecay: IUniform<number>
uImpulse: IUniform<number>
uImpulseCenter: IUniform<Vector2>
uImpulseDirection: IUniform<Vector2>
uImpulseOffset: IUniform<number>
uImpulseSigma: IUniform<number>
uImpulseSpeed: IUniform<number>
uPrevious: IUniform<Texture | null>
uPropagation: IUniform<number>
uQuality: IUniform<number>
uReset: IUniform<number>
uRestoring: IUniform<number>
uStep: IUniform<number>
uTexelSize: IUniform<Vector2>
uVelocityDecay: IUniform<number>
uViewportSize: IUniform<Vector2>
}
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<GlassRippleDynamics> {
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
},
}
}