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

View File

@@ -19,6 +19,7 @@ import {
type GlassOpticalPreset,
type GlassOpticalPresetOverrides,
} from '@/utils/glassOptics'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
@@ -38,6 +39,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const display = useDisplay()
const usesMobilePresentation = useGlassMobilePresentation()
const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
@@ -51,7 +53,7 @@ const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
const isSaving = ref(false)
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
const showsDynamicTuning = computed(() => usesRealtimeOptics.value)
const showsDynamicTuning = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
const activePreset = computed<GlassOpticalPreset>(() =>
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.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 }> = [
{ label: 'theme.glassPresetNatural', value: 'natural' },
{ label: 'theme.glassPresetGlide', value: 'glide' },
@@ -410,6 +416,9 @@ onScopeDispose(cancelGlassPreview)
thumb-label
@update:model-value="updateReflectionStrength"
/>
<p class="glass-settings-dialog__hint">
{{ t('theme.glassMaterialStrengthHint') }}
</p>
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3>
@@ -463,10 +472,10 @@ onScopeDispose(cancelGlassPreview)
thumb-label
@update:model-value="updateFlowStrength"
/>
<p class="glass-settings-dialog__hint">
{{ t('theme.glassOpticalStrengthHint') }}
</p>
</div>
<p class="glass-settings-dialog__hint">
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }}
</p>
</section>
</VCardText>

View File

@@ -23,6 +23,10 @@ const mocks = vi.hoisted(() => ({
cancelGlassPreview: vi.fn(),
commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(),
usesMobilePresentation: null as { value: boolean } | null,
display: {
smAndDown: { value: false },
},
settings: {
value: {
glassAppearance: 'clear',
@@ -53,14 +57,25 @@ vi.mock('vue-i18n', () => ({
}))
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', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
mocks.commitGlassPreview.mockClear()
mocks.previewGlassSettings.mockClear()
mocks.usesMobilePresentation!.value = false
mocks.display.smAndDown.value = false
mocks.settings.value.glassAppearance = 'clear'
mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50
@@ -74,6 +89,7 @@ describe('GlassSettingsDialog', () => {
})
it('cancels an active preview when the parent closes the dialog', async () => {
mocks.display.smAndDown.value = true
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
@@ -367,4 +383,40 @@ describe('GlassSettingsDialog', () => {
'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')
})
})

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
import {
createGlassWallpaperSourceCache,
@@ -72,7 +73,9 @@ const emit = defineEmits<{
const fixedCanvas = 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 wallpaperSourceCache = createGlassWallpaperSourceCache()
const fixedRenderer = useGlassOpticalRenderer({
@@ -80,6 +83,7 @@ const fixedRenderer = useGlassOpticalRenderer({
appearance: () => props.appearance,
canvas: fixedCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
flowStrength: () => props.flowStrength,
interactionSource,
quality: () => props.quality,
@@ -104,6 +108,7 @@ const scrollRenderer = useGlassOpticalRenderer({
appearance: () => props.appearance,
canvas: scrollCanvas,
deformationStrength: () => props.deformationStrength,
dynamicsActive,
flowStrength: () => props.flowStrength,
interactionSource,
pageMotion: pagePresentationMotion.reader,

View File

@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
@@ -24,6 +24,9 @@ const rendererResults = vi.hoisted(
}>,
)
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 setRendererState = vi.hoisted(() =>
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(() => {
mobilePresentationState.current!.value = false
vi.unstubAllGlobals()
})
@@ -135,6 +148,7 @@ describe('GlassOpticalLayer', () => {
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).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)
expect(rendererCalls[0].pageMotion).toBeUndefined()
expect(rendererCalls[1].pageMotion).toEqual(
expect.objectContaining({
@@ -147,6 +161,39 @@ describe('GlassOpticalLayer', () => {
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 () => {
rendererCalls.length = 0
rendererResults.length = 0

View File

@@ -2408,6 +2408,87 @@ describe('glass optical surface discovery', () => {
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 () => {
stubMediaPreferences({ coarsePointer: true })
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)

View File

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

View File

@@ -150,7 +150,9 @@ export function setGlassRendererState(state: Ref<GlassRendererState>, value: Gla
}
/** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */
export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSource {
export function useGlassOpticalInteractionSource(
active: MaybeRefOrGetter<boolean> = true,
): GlassOpticalInteractionSource {
const listeners: Record<GlassPresentationSpace, Set<(event: PointerEvent | TouchEvent) => void>> = {
fixed: new Set(),
scroll: new Set(),
@@ -197,6 +199,13 @@ export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSourc
event.type.startsWith('touch')
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)
? resolveTouchOwner(event)
: resolvePointOwner(event.clientX, event.clientY)
@@ -327,6 +336,8 @@ interface UseGlassOpticalRendererOptions {
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
canvas: Ref<HTMLCanvasElement | null>
deformationStrength?: MaybeRefOrGetter<number>
/** 是否启用交互形变、尾迹和 temporal flow静态材质不受影响。 */
dynamicsActive?: MaybeRefOrGetter<boolean>
flowStrength?: MaybeRefOrGetter<number>
interactionSource?: GlassOpticalInteractionSource
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
@@ -1768,7 +1779,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
if (!resources || !three) return
const profile = getRenderProfile()
if (!profile.flowField) {
if (!hasDynamicCapability() || !profile.flowField) {
disposeFlowResources()
return
}
@@ -2343,23 +2354,37 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT)
}
function hasDynamicCapability() {
return toValue(options.dynamicsActive ?? true)
}
function getTranslationStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength()))
}
function getDeformationStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength()))
}
function getFlowStrengthScale() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
}
function getMotionExpansion() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
}
function getMaxRefractionPixels() {
if (!hasDynamicCapability()) return 0
return getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
@@ -2639,6 +2664,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
timestamp: number,
velocityOverride?: { x: number; y: number },
) {
if (!hasDynamicCapability()) return
const viewportWidth = Math.max(window.innerWidth, 1)
const viewportHeight = Math.max(window.innerHeight, 1)
const presentation = getCommittedPresentationSize()
@@ -2779,6 +2806,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
}
function handleInteractionEvent(event: PointerEvent | TouchEvent) {
if (!hasDynamicCapability()) return
if (event.type === 'pointermove') {
handlePointerMove(event as PointerEvent)
return
@@ -3707,7 +3736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
},
uTranslationStrength: { value: getTranslationStrengthScale() },
uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) },
uTrailCount: { value: getRenderProfile().trailCount },
uTrailCount: { value: hasDynamicCapability() ? getRenderProfile().trailCount : 0 },
uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
uScrollOffset: { value: new three.Vector2(0, 0) },
uWakeDirection: { value: new three.Vector2(0, -1) },
@@ -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(
() => toValue(options.quality),
async (quality, previousQuality) => {
@@ -3857,12 +3911,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
const applyQuality = () => {
if (!resources || toValue(options.quality) !== quality) return
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels(
nextProfile.maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
)
resources.uniforms.uMaxRefractionPixels.value = hasDynamicCapability()
? getGlassOpticalMaxRefractionPixels(
nextProfile.maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
)
: 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
cancelScheduledFrame()
resetInteractionState()
@@ -3912,14 +3968,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
]) => {
if (!resources) return
resources.uniforms.uTranslationStrength.value = getGlassOpticalTranslationStrengthScale(translationStrength)
resources.uniforms.uDeformationStrength.value = getGlassOpticalDeformationStrengthScale(deformationStrength)
resources.uniforms.uFlowStrength.value = getGlassOpticalFlowStrengthScale(flowStrength)
resources.uniforms.uMotionExpansion.value = getGlassOpticalMotionExpansion(flowStrength)
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels,
deformationStrength,
)
const dynamicsActive = hasDynamicCapability()
resources.uniforms.uTranslationStrength.value = dynamicsActive
? getGlassOpticalTranslationStrengthScale(translationStrength)
: 0
resources.uniforms.uDeformationStrength.value = dynamicsActive
? getGlassOpticalDeformationStrengthScale(deformationStrength)
: 0
resources.uniforms.uFlowStrength.value = dynamicsActive ? getGlassOpticalFlowStrengthScale(flowStrength) : 0
resources.uniforms.uMotionExpansion.value = dynamicsActive ? getGlassOpticalMotionExpansion(flowStrength) : 0
resources.uniforms.uMaxRefractionPixels.value = dynamicsActive
? getGlassOpticalMaxRefractionPixels(getRenderProfile().maxRefractionPixels, deformationStrength)
: 0
resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength)
const materialResponse = getGlassMaterialResponse(toValue(options.appearance), transparencyStrength)
resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility

View File

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

View File

@@ -160,6 +160,7 @@ export default {
'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.',
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',
glassPresetNatural: 'Natural',
glassPresetGlide: 'Glide',
@@ -172,10 +173,10 @@ export default {
glassReflectionStrength: 'Reflection Brightness',
glassTransmissionStrength: 'Transmission Brightness',
glassTransparencyStrength: 'Clarity',
glassMaterialStrengthHint:
'Clarity controls wallpaper texture visibility. Transmission Brightness controls the background inside the glass. Reflection Brightness controls surface reflections and highlights.',
glassOpticalStrengthHint:
'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',
custom: 'Custom Style',
transparency: 'Transparency',

View File

@@ -157,6 +157,7 @@ export default {
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透、透射与反射,不启用实时流动。',
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
glassQualityHighHint: '完整时序流场、扩散细节与内容保护GPU 占用更高。',
glassQualityMobileHint: '保留所选档位的材质精度;移动端不启用实时流动。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
@@ -169,8 +170,9 @@ export default {
glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassMaterialStrengthHint:
'通透度控制壁纸纹理的可见程度,透射亮度控制玻璃内的背景明暗,反射亮度控制表面反射与高光强度。',
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数,切换到均衡或高质量后,可调整采样平移、形变和流动。',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',

View File

@@ -157,6 +157,7 @@ export default {
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透、透射與反射,不啟用即時流動。',
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
glassQualityHighHint: '完整時序流場、擴散細節與內容保護GPU 佔用更高。',
glassQualityMobileHint: '保留所選檔位的材質精度;行動裝置不啟用即時流動。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
@@ -169,8 +170,9 @@ export default {
glassReflectionStrength: '反射亮度',
glassTransmissionStrength: '透射亮度',
glassTransparencyStrength: '通透度',
glassMaterialStrengthHint:
'通透度控制桌布紋理的可見程度,透射亮度控制玻璃內的背景明暗,反射亮度控制表面反射與高光強度。',
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數,切換到均衡或高品質後,可調整採樣平移、形變和流動。',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',