fix(glass): disable mobile motion dynamics (#623)

This commit is contained in:
InfinityPacer
2026-08-01 07:27:58 +08:00
committed by GitHub
parent 88f81ecce4
commit eedf038a70
11 changed files with 349 additions and 28 deletions
+13 -4
View File
@@ -19,6 +19,7 @@ import {
type GlassOpticalPreset, type GlassOpticalPreset,
type GlassOpticalPresetOverrides, type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics' } from '@/utils/glassOptics'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -38,6 +39,7 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const display = useDisplay() const display = useDisplay()
const usesMobilePresentation = useGlassMobilePresentation()
const { settings } = useThemeCustomizer() const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance) const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength) const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
@@ -51,7 +53,7 @@ const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength) const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
const isSaving = ref(false) const isSaving = ref(false)
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css') const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
const showsDynamicTuning = computed(() => usesRealtimeOptics.value) const showsDynamicTuning = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value)) const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
const activePreset = computed<GlassOpticalPreset>(() => const activePreset = computed<GlassOpticalPreset>(() =>
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural', availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
@@ -105,7 +107,11 @@ const qualityOptions: Array<{
{ hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' }, { hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' },
{ hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' }, { hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' },
] ]
const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '') const qualityHint = computed(() => {
if (usesMobilePresentation.value && draftQuality.value !== 'css') return 'theme.glassQualityMobileHint'
return qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? ''
})
const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [ const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
{ label: 'theme.glassPresetNatural', value: 'natural' }, { label: 'theme.glassPresetNatural', value: 'natural' },
{ label: 'theme.glassPresetGlide', value: 'glide' }, { label: 'theme.glassPresetGlide', value: 'glide' },
@@ -410,6 +416,9 @@ onScopeDispose(cancelGlassPreview)
thumb-label thumb-label
@update:model-value="updateReflectionStrength" @update:model-value="updateReflectionStrength"
/> />
<p class="glass-settings-dialog__hint">
{{ t('theme.glassMaterialStrengthHint') }}
</p>
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls"> <div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3> <h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3>
@@ -463,10 +472,10 @@ onScopeDispose(cancelGlassPreview)
thumb-label thumb-label
@update:model-value="updateFlowStrength" @update:model-value="updateFlowStrength"
/> />
</div>
<p class="glass-settings-dialog__hint"> <p class="glass-settings-dialog__hint">
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }} {{ t('theme.glassOpticalStrengthHint') }}
</p> </p>
</div>
</section> </section>
</VCardText> </VCardText>
@@ -23,6 +23,10 @@ const mocks = vi.hoisted(() => ({
cancelGlassPreview: vi.fn(), cancelGlassPreview: vi.fn(),
commitGlassPreview: vi.fn(), commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(), previewGlassSettings: vi.fn(),
usesMobilePresentation: null as { value: boolean } | null,
display: {
smAndDown: { value: false },
},
settings: { settings: {
value: { value: {
glassAppearance: 'clear', glassAppearance: 'clear',
@@ -53,14 +57,25 @@ vi.mock('vue-i18n', () => ({
})) }))
vi.mock('vuetify', () => ({ vi.mock('vuetify', () => ({
useDisplay: () => ({ smAndDown: { value: true } }), useDisplay: () => mocks.display,
})) }))
vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
const { ref } = await vi.importActual<typeof import('vue')>('vue')
mocks.usesMobilePresentation = ref(false)
return {
useGlassMobilePresentation: () => mocks.usesMobilePresentation,
}
})
describe('GlassSettingsDialog', () => { describe('GlassSettingsDialog', () => {
beforeEach(() => { beforeEach(() => {
mocks.cancelGlassPreview.mockClear() mocks.cancelGlassPreview.mockClear()
mocks.commitGlassPreview.mockClear() mocks.commitGlassPreview.mockClear()
mocks.previewGlassSettings.mockClear() mocks.previewGlassSettings.mockClear()
mocks.usesMobilePresentation!.value = false
mocks.display.smAndDown.value = false
mocks.settings.value.glassAppearance = 'clear' mocks.settings.value.glassAppearance = 'clear'
mocks.settings.value.glassDeformationStrength = 50 mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50 mocks.settings.value.glassFlowStrength = 50
@@ -74,6 +89,7 @@ describe('GlassSettingsDialog', () => {
}) })
it('cancels an active preview when the parent closes the dialog', async () => { it('cancels an active preview when the parent closes the dialog', async () => {
mocks.display.smAndDown.value = true
const wrapper = shallowMount(GlassSettingsDialog, { const wrapper = shallowMount(GlassSettingsDialog, {
global: { global: {
stubs: { stubs: {
@@ -367,4 +383,40 @@ describe('GlassSettingsDialog', () => {
'theme.glassFlowStrength', 'theme.glassFlowStrength',
]) ])
}) })
it('restores motion tuning when a mobile presentation returns to desktop', async () => {
mocks.usesMobilePresentation!.value = true
mocks.settings.value.glassQuality = 'high'
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardActions: slotStub,
VCardText: slotStub,
VDialog: dialogStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
expect(wrapper.findAll('.slider-stub').map(slider => slider.attributes('aria-label'))).toEqual([
'theme.glassTransparencyStrength',
'theme.glassTransmissionStrength',
'theme.glassReflectionStrength',
])
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint')
expect(wrapper.text()).toContain('theme.glassQualityMobileHint')
mocks.usesMobilePresentation!.value = false
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(true)
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
expect(wrapper.text()).toContain('theme.glassOpticalStrengthHint')
})
}) })
+6 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer' import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion' import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import { import {
createGlassWallpaperSourceCache, createGlassWallpaperSourceCache,
@@ -72,7 +73,9 @@ const emit = defineEmits<{
const fixedCanvas = ref<HTMLCanvasElement | null>(null) const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null) const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource() const usesMobilePresentation = useGlassMobilePresentation()
const dynamicsActive = computed(() => !usesMobilePresentation.value)
const interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
const pagePresentationMotion = usePagePresentationMotion() const pagePresentationMotion = usePagePresentationMotion()
const wallpaperSourceCache = createGlassWallpaperSourceCache() const wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({ const fixedRenderer = useGlassOpticalRenderer({
@@ -80,6 +83,7 @@ const fixedRenderer = useGlassOpticalRenderer({
appearance: () => props.appearance, appearance: () => props.appearance,
canvas: fixedCanvas, canvas: fixedCanvas,
deformationStrength: () => props.deformationStrength, deformationStrength: () => props.deformationStrength,
dynamicsActive,
flowStrength: () => props.flowStrength, flowStrength: () => props.flowStrength,
interactionSource, interactionSource,
quality: () => props.quality, quality: () => props.quality,
@@ -104,6 +108,7 @@ const scrollRenderer = useGlassOpticalRenderer({
appearance: () => props.appearance, appearance: () => props.appearance,
canvas: scrollCanvas, canvas: scrollCanvas,
deformationStrength: () => props.deformationStrength, deformationStrength: () => props.deformationStrength,
dynamicsActive,
flowStrength: () => props.flowStrength, flowStrength: () => props.flowStrength,
interactionSource, interactionSource,
pageMotion: pagePresentationMotion.reader, pageMotion: pagePresentationMotion.reader,
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils' import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue' import { nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue' import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
@@ -24,6 +24,9 @@ const rendererResults = vi.hoisted(
}>, }>,
) )
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() })) const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
const mobilePresentationState = vi.hoisted(() => ({
current: null as { value: boolean } | null,
}))
const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() })) const wallpaperSourceCache = vi.hoisted(() => ({ get: vi.fn() }))
const setRendererState = vi.hoisted(() => const setRendererState = vi.hoisted(() =>
vi.fn((state: { value: string }, value: string) => { vi.fn((state: { value: string }, value: string) => {
@@ -100,7 +103,17 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
}), }),
})) }))
vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
const { ref: createRef } = await vi.importActual<typeof import('vue')>('vue')
mobilePresentationState.current = createRef(false)
return {
useGlassMobilePresentation: () => mobilePresentationState.current,
}
})
afterEach(() => { afterEach(() => {
mobilePresentationState.current!.value = false
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
@@ -135,6 +148,7 @@ describe('GlassOpticalLayer', () => {
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true) expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).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.syncDocumentState === false)).toBe(true)
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
expect(rendererCalls[0].pageMotion).toBeUndefined() expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual( expect(rendererCalls[1].pageMotion).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -147,6 +161,39 @@ describe('GlassOpticalLayer', () => {
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback') expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
}) })
it('keeps both material contexts while disabling dynamics on mobile presentations', async () => {
rendererCalls.length = 0
rendererResults.length = 0
mobilePresentationState.current!.value = true
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'frosted',
deformationStrength: 50,
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'high',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
transmissionStrength: 50,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
},
})
expect(wrapper.findAll('canvas')).toHaveLength(2)
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
mobilePresentationState.current!.value = false
await nextTick()
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
wrapper.unmount()
})
it('activates matching resource bundles in one shared animation frame', async () => { it('activates matching resource bundles in one shared animation frame', async () => {
rendererCalls.length = 0 rendererCalls.length = 0
rendererResults.length = 0 rendererResults.length = 0
@@ -2408,6 +2408,87 @@ describe('glass optical surface discovery', () => {
scope.stop() scope.stop()
}) })
it('keeps high-quality material rendering while mobile dynamics are disabled', async () => {
stubMediaPreferences({ coarsePointer: true })
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const canvas = document.createElement('canvas')
const deformationStrength = ref(80)
const dynamicsActive = ref(false)
const flowStrength = ref(80)
const reflectionStrength = ref(80)
const translationStrength = ref(80)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('frosted'),
canvas: ref(canvas),
deformationStrength,
dynamicsActive,
flowStrength,
quality: ref('high'),
reflectionStrength,
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
transmissionStrength: ref(80),
translationStrength,
transparencyStrength: ref(80),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uDeformationStrength: { value: number }
uFlowStrength: { value: number }
uHasFlowTexture: { value: number }
uReflectionStrength: { value: number }
uTrailCount: { value: number }
uTranslationStrength: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
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.uReflectionStrength.value).toBeGreaterThan(0)
const renderedFrames = renderer?.renderedFrames.value
dispatchTouchEvent('touchstart', [{ clientX: 80, clientY: 180, identifier: 7 }])
dispatchTouchEvent('touchmove', [{ clientX: 180, clientY: 320, identifier: 7 }])
await nextTick()
expect(renderer?.renderedFrames.value).toBe(renderedFrames)
translationStrength.value = 100
deformationStrength.value = 100
flowStrength.value = 100
reflectionStrength.value = 100
await nextTick()
expect(uniforms.uTranslationStrength.value).toBe(0)
expect(uniforms.uDeformationStrength.value).toBe(0)
expect(uniforms.uFlowStrength.value).toBe(0)
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
dynamicsActive.value = true
await nextTick()
expect(uniforms.uTranslationStrength.value).toBeGreaterThan(0)
expect(uniforms.uDeformationStrength.value).toBeGreaterThan(0)
expect(uniforms.uFlowStrength.value).toBeGreaterThan(0)
expect(uniforms.uTrailCount.value).toBe(4)
expect(uniforms.uHasFlowTexture.value).toBe(1)
scope.stop()
})
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => { it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
stubMediaPreferences({ coarsePointer: true }) stubMediaPreferences({ coarsePointer: true })
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390) vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
const mocks = vi.hoisted(() => ({
smAndDown: null as { value: boolean } | null,
usesTouchInput: null as { value: boolean } | null,
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await vi.importActual<typeof import('vue')>('vue')
mocks.usesTouchInput = ref(false)
return {
useMediaQuery: vi.fn(() => mocks.usesTouchInput),
}
})
vi.mock('vuetify', async () => {
const { ref } = await vi.importActual<typeof import('vue')>('vue')
mocks.smAndDown = ref(false)
return {
useDisplay: () => ({ smAndDown: mocks.smAndDown }),
}
})
describe('useGlassMobilePresentation', () => {
beforeEach(() => {
mocks.smAndDown!.value = false
mocks.usesTouchInput!.value = false
})
it('reacts when DevTools switches between touch and desktop input', () => {
const usesMobilePresentation = useGlassMobilePresentation()
mocks.usesTouchInput!.value = true
expect(usesMobilePresentation.value).toBe(true)
mocks.usesTouchInput!.value = false
expect(usesMobilePresentation.value).toBe(false)
})
it('keeps small viewports on the mobile presentation path', () => {
const usesMobilePresentation = useGlassMobilePresentation()
mocks.smAndDown!.value = true
expect(usesMobilePresentation.value).toBe(true)
})
})
+73 -13
View File
@@ -150,7 +150,9 @@ export function setGlassRendererState(state: Ref<GlassRendererState>, value: Gla
} }
/** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */ /** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */
export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSource { export function useGlassOpticalInteractionSource(
active: MaybeRefOrGetter<boolean> = true,
): GlassOpticalInteractionSource {
const listeners: Record<GlassPresentationSpace, Set<(event: PointerEvent | TouchEvent) => void>> = { const listeners: Record<GlassPresentationSpace, Set<(event: PointerEvent | TouchEvent) => void>> = {
fixed: new Set(), fixed: new Set(),
scroll: new Set(), scroll: new Set(),
@@ -197,6 +199,13 @@ export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSourc
event.type.startsWith('touch') event.type.startsWith('touch')
const dispatch = (event: PointerEvent | TouchEvent) => { const dispatch = (event: PointerEvent | TouchEvent) => {
if (!toValue(active)) {
if (isTouchInteractionEvent(event) && (event.type === 'touchend' || event.type === 'touchcancel')) {
for (const touch of Array.from(event.changedTouches)) touchOwners.delete(touch.identifier)
}
return
}
const owner = isTouchInteractionEvent(event) const owner = isTouchInteractionEvent(event)
? resolveTouchOwner(event) ? resolveTouchOwner(event)
: resolvePointOwner(event.clientX, event.clientY) : resolvePointOwner(event.clientX, event.clientY)
@@ -327,6 +336,8 @@ interface UseGlassOpticalRendererOptions {
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance> appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
canvas: Ref<HTMLCanvasElement | null> canvas: Ref<HTMLCanvasElement | null>
deformationStrength?: MaybeRefOrGetter<number> deformationStrength?: MaybeRefOrGetter<number>
/** 是否启用交互形变、尾迹和 temporal flow;静态材质不受影响。 */
dynamicsActive?: MaybeRefOrGetter<boolean>
flowStrength?: MaybeRefOrGetter<number> flowStrength?: MaybeRefOrGetter<number>
interactionSource?: GlassOpticalInteractionSource interactionSource?: GlassOpticalInteractionSource
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */ /** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
@@ -1768,7 +1779,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (!resources || !three) return if (!resources || !three) return
const profile = getRenderProfile() const profile = getRenderProfile()
if (!profile.flowField) { if (!hasDynamicCapability() || !profile.flowField) {
disposeFlowResources() disposeFlowResources()
return return
} }
@@ -2343,23 +2354,37 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT) return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT)
} }
function hasDynamicCapability() {
return toValue(options.dynamicsActive ?? true)
}
function getTranslationStrengthScale() { function getTranslationStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength())) return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength()))
} }
function getDeformationStrengthScale() { function getDeformationStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength())) return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength()))
} }
function getFlowStrengthScale() { function getFlowStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength())) return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
} }
function getMotionExpansion() { function getMotionExpansion() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength())) return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
} }
function getMaxRefractionPixels() { function getMaxRefractionPixels() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalMaxRefractionPixels( return getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels, getRenderProfile().maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()), toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
@@ -2639,6 +2664,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
timestamp: number, timestamp: number,
velocityOverride?: { x: number; y: number }, velocityOverride?: { x: number; y: number },
) { ) {
if (!hasDynamicCapability()) return
const viewportWidth = Math.max(window.innerWidth, 1) const viewportWidth = Math.max(window.innerWidth, 1)
const viewportHeight = Math.max(window.innerHeight, 1) const viewportHeight = Math.max(window.innerHeight, 1)
const presentation = getCommittedPresentationSize() const presentation = getCommittedPresentationSize()
@@ -2779,6 +2806,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
} }
function handleInteractionEvent(event: PointerEvent | TouchEvent) { function handleInteractionEvent(event: PointerEvent | TouchEvent) {
if (!hasDynamicCapability()) return
if (event.type === 'pointermove') { if (event.type === 'pointermove') {
handlePointerMove(event as PointerEvent) handlePointerMove(event as PointerEvent)
return return
@@ -3707,7 +3736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}, },
uTranslationStrength: { value: getTranslationStrengthScale() }, uTranslationStrength: { value: getTranslationStrengthScale() },
uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) }, uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) },
uTrailCount: { value: getRenderProfile().trailCount }, uTrailCount: { value: hasDynamicCapability() ? getRenderProfile().trailCount : 0 },
uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) }, uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
uScrollOffset: { value: new three.Vector2(0, 0) }, uScrollOffset: { value: new three.Vector2(0, 0) },
uWakeDirection: { value: new three.Vector2(0, -1) }, uWakeDirection: { value: new three.Vector2(0, -1) },
@@ -3847,6 +3876,31 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}, },
) )
watch(
() => toValue(options.dynamicsActive ?? true),
active => {
if (!resources) return
interactionAnimating = false
activeTouchIdentifier = null
cancelScheduledFrame()
resetInteractionState()
resources.uniforms.uTranslationStrength.value = active ? getTranslationStrengthScale() : 0
resources.uniforms.uDeformationStrength.value = active ? getDeformationStrengthScale() : 0
resources.uniforms.uFlowStrength.value = active ? getFlowStrengthScale() : 0
resources.uniforms.uMotionExpansion.value = active ? getMotionExpansion() : 0
resources.uniforms.uMaxRefractionPixels.value = active
? getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
)
: 0
resources.uniforms.uTrailCount.value = active ? getRenderProfile().trailCount : 0
syncFlowResources()
scheduleFrame()
},
)
watch( watch(
() => toValue(options.quality), () => toValue(options.quality),
async (quality, previousQuality) => { async (quality, previousQuality) => {
@@ -3857,12 +3911,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const applyQuality = () => { const applyQuality = () => {
if (!resources || toValue(options.quality) !== quality) return if (!resources || toValue(options.quality) !== quality) return
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels( resources.uniforms.uMaxRefractionPixels.value = hasDynamicCapability()
? getGlassOpticalMaxRefractionPixels(
nextProfile.maxRefractionPixels, nextProfile.maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()), toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
) )
: 0
resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0 resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0
resources.uniforms.uTrailCount.value = nextProfile.trailCount resources.uniforms.uTrailCount.value = hasDynamicCapability() ? nextProfile.trailCount : 0
interactionAnimating = false interactionAnimating = false
cancelScheduledFrame() cancelScheduledFrame()
resetInteractionState() resetInteractionState()
@@ -3912,14 +3968,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
]) => { ]) => {
if (!resources) return if (!resources) return
resources.uniforms.uTranslationStrength.value = getGlassOpticalTranslationStrengthScale(translationStrength) const dynamicsActive = hasDynamicCapability()
resources.uniforms.uDeformationStrength.value = getGlassOpticalDeformationStrengthScale(deformationStrength) resources.uniforms.uTranslationStrength.value = dynamicsActive
resources.uniforms.uFlowStrength.value = getGlassOpticalFlowStrengthScale(flowStrength) ? getGlassOpticalTranslationStrengthScale(translationStrength)
resources.uniforms.uMotionExpansion.value = getGlassOpticalMotionExpansion(flowStrength) : 0
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels( resources.uniforms.uDeformationStrength.value = dynamicsActive
getRenderProfile().maxRefractionPixels, ? getGlassOpticalDeformationStrengthScale(deformationStrength)
deformationStrength, : 0
) resources.uniforms.uFlowStrength.value = dynamicsActive ? getGlassOpticalFlowStrengthScale(flowStrength) : 0
resources.uniforms.uMotionExpansion.value = dynamicsActive ? getGlassOpticalMotionExpansion(flowStrength) : 0
resources.uniforms.uMaxRefractionPixels.value = dynamicsActive
? getGlassOpticalMaxRefractionPixels(getRenderProfile().maxRefractionPixels, deformationStrength)
: 0
resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength) resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength)
const materialResponse = getGlassMaterialResponse(toValue(options.appearance), transparencyStrength) const materialResponse = getGlassMaterialResponse(toValue(options.appearance), transparencyStrength)
resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility
@@ -0,0 +1,13 @@
import { useMediaQuery } from '@vueuse/core'
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
const TOUCH_PRESENTATION_QUERY = '(hover: none), (pointer: coarse)'
/** 根据当前视口和主输入能力判断玻璃是否使用移动端静态呈现。 */
export function useGlassMobilePresentation() {
const { smAndDown } = useDisplay()
const usesTouchInput = useMediaQuery(TOUCH_PRESENTATION_QUERY)
return computed(() => smAndDown.value || usesTouchInput.value)
}
+3 -2
View File
@@ -160,6 +160,7 @@ export default {
'Static CSS material with the lowest resource use; clarity, transmission, and reflection remain available.', 'Static CSS material with the lowest resource use; clarity, transmission, and reflection remain available.',
glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.', glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.',
glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.', glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.',
glassQualityMobileHint: 'Keeps the selected material quality; real-time motion is disabled on mobile.',
glassPreset: 'Preset', glassPreset: 'Preset',
glassPresetNatural: 'Natural', glassPresetNatural: 'Natural',
glassPresetGlide: 'Glide', glassPresetGlide: 'Glide',
@@ -172,10 +173,10 @@ export default {
glassReflectionStrength: 'Reflection Brightness', glassReflectionStrength: 'Reflection Brightness',
glassTransmissionStrength: 'Transmission Brightness', glassTransmissionStrength: 'Transmission Brightness',
glassTransparencyStrength: 'Clarity', glassTransparencyStrength: 'Clarity',
glassMaterialStrengthHint:
'Clarity controls wallpaper texture visibility. Transmission Brightness controls the background inside the glass. Reflection Brightness controls surface reflections and highlights.',
glassOpticalStrengthHint: glassOpticalStrengthHint:
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.', 'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
glassOpticalStrengthUnavailableHint:
'Standard quality keeps all three material controls; switch to Balanced or High to adjust sample translation, deformation, and flow.',
purple: 'Purple', purple: 'Purple',
custom: 'Custom Style', custom: 'Custom Style',
transparency: 'Transparency', transparency: 'Transparency',
+3 -1
View File
@@ -157,6 +157,7 @@ export default {
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透、透射与反射,不启用实时流动。', glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透、透射与反射,不启用实时流动。',
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。', glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。', glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。',
glassQualityMobileHint: '保留所选档位的材质精度;移动端不启用实时流动。',
glassPreset: '方案', glassPreset: '方案',
glassPresetNatural: '自然', glassPresetNatural: '自然',
glassPresetGlide: '滑移', glassPresetGlide: '滑移',
@@ -169,8 +170,9 @@ export default {
glassReflectionStrength: '反射亮度', glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度', glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度', glassTransparencyStrength: '通透度',
glassMaterialStrengthHint:
'通透度控制壁纸纹理的可见程度,透射亮度控制玻璃内的背景明暗,反射亮度控制表面反射与高光强度。',
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。', glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数,切换到均衡或高质量后,可调整采样平移、形变和流动。',
purple: '幻紫', purple: '幻紫',
custom: '附加样式', custom: '附加样式',
transparency: '透明度', transparency: '透明度',
+3 -1
View File
@@ -157,6 +157,7 @@ export default {
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透、透射與反射,不啟用即時流動。', glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透、透射與反射,不啟用即時流動。',
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。', glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。', glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。',
glassQualityMobileHint: '保留所選檔位的材質精度;行動裝置不啟用即時流動。',
glassPreset: '方案', glassPreset: '方案',
glassPresetNatural: '自然', glassPresetNatural: '自然',
glassPresetGlide: '滑移', glassPresetGlide: '滑移',
@@ -169,8 +170,9 @@ export default {
glassReflectionStrength: '反射亮度', glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度', glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度', glassTransparencyStrength: '通透度',
glassMaterialStrengthHint:
'通透度控制桌布紋理的可見程度,透射亮度控制玻璃內的背景明暗,反射亮度控制表面反射與高光強度。',
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。', glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數,切換到均衡或高品質後,可調整採樣平移、形變和流動。',
purple: '幻紫', purple: '幻紫',
custom: '附加樣式', custom: '附加樣式',
transparency: '透明度', transparency: '透明度',