feat(glass): improve optical quality and controls (#585)

This commit is contained in:
InfinityPacer
2026-07-25 10:30:29 +08:00
committed by GitHub
parent b3e596c761
commit 27f6c33782
18 changed files with 4319 additions and 305 deletions

View File

@@ -27,7 +27,8 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
const LOGIN_WALLPAPER_ROUTE = '/login'
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
@@ -74,6 +75,7 @@ const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const previousImageIndex = ref<number | null>(null)
const isBackgroundCrossfading = ref(false)
const backgroundCrossfadeStartedAt = ref(0)
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
const preferredMotion = usePreferredReducedMotion()
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
@@ -84,16 +86,37 @@ const effectiveGlassSettings = useEffectiveGlassSettings()
const isInitialRouteReady = ref(false)
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
// 登录专题保持固定光学参数,避免全局用户偏好改变其独立视觉合成。
const opticalDeformationStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassDeformationStrength,
)
const opticalFlowStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassFlowStrength,
)
const opticalReflectionStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassReflectionStrength,
)
const opticalTransparencyStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTransparencyStrength,
)
const opticalTranslationStrength = computed(() =>
isLoginWallpaperRoute.value ? GLASS_OPTICAL_STRENGTH_DEFAULT : effectiveGlassSettings.value.glassTranslationStrength,
)
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
const shouldLoadBackgroundImages = computed(
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
)
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。
const activeOpticalBackgroundImage = computed(() =>
getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)),
)
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
const previousOpticalBackgroundImage = computed(() => {
const previousIndex = previousImageIndex.value
if (previousIndex === null) return ''
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
})
const appWrapperStyle = computed(() => ({
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
}))
@@ -102,7 +125,6 @@ const shouldRenderGlassOpticalLayer = computed(
isGlassTheme.value &&
effectiveGlassSettings.value.glassQuality !== 'css' &&
isInitialRouteReady.value &&
!isRenderThrottled.value &&
Boolean(activeBackgroundImage.value),
)
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
@@ -350,6 +372,7 @@ function resetBackgroundCrossfade() {
clearBackgroundCrossfadeTimer()
previousImageIndex.value = null
isBackgroundCrossfading.value = false
backgroundCrossfadeStartedAt.value = 0
}
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
@@ -359,6 +382,7 @@ function activateBackgroundImage(nextIndex: number) {
clearBackgroundCrossfadeTimer()
previousImageIndex.value = activeImageIndex.value
isBackgroundCrossfading.value = true
backgroundCrossfadeStartedAt.value = performance.now()
activeImageIndex.value = nextIndex
backgroundCrossfadeTimer = window.setTimeout(() => {
previousImageIndex.value = null
@@ -390,11 +414,21 @@ function rotateBackgroundImage() {
// 计算下一个图片索引
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
const requestVersion = ++backgroundRotationVersion
const nextImage = backgroundImages.value[nextIndex]
const opticalImage =
shouldRenderGlassOpticalLayer.value && !isLoginWallpaperRoute.value
? getOpticalBackgroundImage(nextImage)
: undefined
void commitPreloadedBackgroundRotation({
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
commit: () => activateBackgroundImage(nextIndex),
preload: () => preloadImage(backgroundImages.value[nextIndex]),
preload: () =>
preloadBackgroundRotationImages({
displayUrl: nextImage,
opticalUrl: opticalImage,
preload: preloadImage,
}),
})
}
}
@@ -679,11 +713,18 @@ onUnmounted(() => {
<GlassOpticalLayer
v-if="shouldRenderGlassOpticalLayer"
:appearance="effectiveGlassSettings.glassAppearance"
:class="{ 'glass-optical-layer--background-transition': isBackgroundCrossfading }"
:deformation-strength="opticalDeformationStrength"
:flow-strength="opticalFlowStrength"
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
:reflection-strength="opticalReflectionStrength"
:transparency-strength="opticalTransparencyStrength"
:translation-strength="opticalTranslationStrength"
:route-key="route.fullPath"
:tint-color="globalTheme.current.value.colors.primary"
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
:transition-started-at="backgroundCrossfadeStartedAt"
:wallpaper-url="activeOpticalBackgroundImage"
:previous-wallpaper-url="previousOpticalBackgroundImage"
/>
<!-- 页面内容 -->
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">

View File

@@ -7,6 +7,14 @@ import {
type ThemeCustomizerGlassAppearance,
type ThemeCustomizerGlassQuality,
} from '@/composables/useThemeCustomizer'
import {
GLASS_OPTICAL_STRENGTH_MAX,
GLASS_OPTICAL_STRENGTH_MIN,
getAvailableGlassOpticalPresets,
getGlassOpticalPresetParameters,
normalizeGlassOpticalStrength,
type GlassOpticalPreset,
} from '@/utils/glassOptics'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
@@ -26,8 +34,20 @@ const emit = defineEmits<{
const { t } = useI18n()
const { settings } = useThemeCustomizer()
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
const draftFlowStrength = ref(settings.value.glassFlowStrength)
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
const isSaving = ref(false)
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
const showsDynamicTuning = computed(() => usesRealtimeOptics.value)
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
const activePreset = computed<GlassOpticalPreset>(() =>
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
)
const visible = computed({
get: () => props.modelValue,
@@ -44,7 +64,13 @@ watch(
(value, previous) => {
if (value) {
draftAppearance.value = settings.value.glassAppearance
draftDeformationStrength.value = settings.value.glassDeformationStrength
draftFlowStrength.value = settings.value.glassFlowStrength
draftPreset.value = settings.value.glassPreset
draftQuality.value = settings.value.glassQuality
draftReflectionStrength.value = settings.value.glassReflectionStrength
draftTranslationStrength.value = settings.value.glassTranslationStrength
draftTransparencyStrength.value = settings.value.glassTransparencyStrength
} else if (previous) {
cancelGlassPreview()
}
@@ -61,13 +87,23 @@ const appearanceOptions: Array<{
]
const qualityOptions: Array<{
hint: string
label: string
value: ThemeCustomizerGlassQuality
}> = [
{ label: 'theme.glassQualityCss', value: 'css' },
{ label: 'theme.glassQualityBalanced', value: 'balanced' },
{ label: 'theme.glassQualityHigh', value: 'high' },
{ hint: 'theme.glassQualityCssHint', label: 'theme.glassQualityCss', value: 'css' },
{ hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' },
{ hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' },
]
const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '')
const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
{ label: 'theme.glassPresetNatural', value: 'natural' },
{ label: 'theme.glassPresetGlide', value: 'glide' },
{ label: 'theme.glassPresetLiquid', value: 'liquid' },
]
const visiblePresetOptions = computed(() =>
presetOptions.filter(option => availablePresets.value.includes(option.value)),
)
/** 仅允许已实现的材质进入待保存设置。 */
function updateAppearance(value: unknown) {
@@ -86,16 +122,68 @@ function updateQuality(value: unknown) {
previewGlassSettings({ glassQuality: option.value })
}
/** 将当前草稿恢复为玻璃主题默认值并立即预览。 */
function resetSettings() {
draftAppearance.value = 'clear'
draftQuality.value = 'balanced'
/** 将五个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
function previewDraftParameters() {
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassQuality: draftQuality.value,
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTranslationStrength: draftTranslationStrength.value,
glassTransparencyStrength: draftTransparencyStrength.value,
})
}
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
function applyPreset(value: unknown) {
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
if (!availablePresets.value.includes(value)) return
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
draftPreset.value = value
draftDeformationStrength.value = parameters.deformation
draftFlowStrength.value = parameters.flow
draftReflectionStrength.value = parameters.reflection
draftTranslationStrength.value = parameters.translation
draftTransparencyStrength.value = parameters.transparency
previewDraftParameters()
}
/** 将采样平移限制为 renderer 支持的稳定范围。 */
function updateTranslationStrength(value: unknown) {
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
}
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
function updateDeformationStrength(value: unknown) {
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
}
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
function updateFlowStrength(value: unknown) {
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
}
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
function updateReflectionStrength(value: unknown) {
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
}
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
function updateTransparencyStrength(value: unknown) {
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
}
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
function resetSettings() {
applyPreset(activePreset.value)
}
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
async function saveSettings() {
if (isSaving.value) return
@@ -105,7 +193,13 @@ async function saveSettings() {
try {
previewGlassSettings({
glassAppearance: draftAppearance.value,
glassDeformationStrength: draftDeformationStrength.value,
glassFlowStrength: draftFlowStrength.value,
glassPreset: draftPreset.value,
glassQuality: draftQuality.value,
glassReflectionStrength: draftReflectionStrength.value,
glassTranslationStrength: draftTranslationStrength.value,
glassTransparencyStrength: draftTransparencyStrength.value,
})
commitGlassPreview()
visible.value = false
@@ -171,6 +265,122 @@ onScopeDispose(cancelGlassPreview)
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
<p class="glass-settings-dialog__hint">{{ t(qualityHint) }}</p>
</section>
<section v-if="usesRealtimeOptics">
<div class="glass-settings-dialog__preset-header">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassPreset') }}</h3>
</div>
<VBtnToggle
:model-value="activePreset"
mandatory
color="primary"
variant="text"
class="glass-settings-dialog__preset"
@update:model-value="applyPreset"
>
<VBtn
v-for="option in visiblePresetOptions"
:key="option.value"
:value="option.value"
class="glass-settings-dialog__preset-option"
>
{{ t(option.label) }}
</VBtn>
</VBtnToggle>
</section>
<section class="glass-settings-dialog__tuning">
<div class="glass-settings-dialog__slider-header">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransparencyStrength') }}</h3>
<output>{{ draftTransparencyStrength }}%</output>
</div>
<VSlider
:model-value="draftTransparencyStrength"
:aria-label="t('theme.glassTransparencyStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateTransparencyStrength"
/>
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassReflectionStrength') }}</h3>
<output>{{ draftReflectionStrength }}%</output>
</div>
<VSlider
:model-value="draftReflectionStrength"
:aria-label="t('theme.glassReflectionStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateReflectionStrength"
/>
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
<div class="glass-settings-dialog__slider-header">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTranslationStrength') }}</h3>
<output>{{ draftTranslationStrength }}%</output>
</div>
<VSlider
:model-value="draftTranslationStrength"
:aria-label="t('theme.glassTranslationStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateTranslationStrength"
/>
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDeformationStrength') }}</h3>
<output>{{ draftDeformationStrength }}%</output>
</div>
<VSlider
:model-value="draftDeformationStrength"
:aria-label="t('theme.glassDeformationStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateDeformationStrength"
/>
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
<h3 class="glass-settings-dialog__label">{{ t('theme.glassFlowStrength') }}</h3>
<output>{{ draftFlowStrength }}%</output>
</div>
<VSlider
:model-value="draftFlowStrength"
:aria-label="t('theme.glassFlowStrength')"
:min="GLASS_OPTICAL_STRENGTH_MIN"
:max="GLASS_OPTICAL_STRENGTH_MAX"
:step="1"
color="primary"
density="comfortable"
hide-details
thumb-label
@update:model-value="updateFlowStrength"
/>
</div>
<p class="glass-settings-dialog__hint">
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }}
</p>
</section>
</VCardText>
@@ -204,8 +414,57 @@ onScopeDispose(cancelGlassPreview)
line-height: 1.4;
}
.glass-settings-dialog__hint {
margin: 8px 0 0;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.45;
}
.glass-settings-dialog__slider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
.glass-settings-dialog__label {
margin-block-end: 0;
}
output {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
}
.glass-settings-dialog__slider-header--spaced,
.glass-settings-dialog__slider-header + .glass-settings-dialog__slider-header {
margin-block-start: 18px;
}
.glass-settings-dialog__live-controls {
margin-block-start: 18px;
:deep(.v-slider) {
margin-block-start: 4px;
}
}
.glass-settings-dialog__preset-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.glass-settings-dialog__label {
margin-block-end: 0;
}
}
.glass-settings-dialog__appearance,
.glass-settings-dialog__quality {
.glass-settings-dialog__quality,
.glass-settings-dialog__preset {
display: grid;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
@@ -226,6 +485,12 @@ onScopeDispose(cancelGlassPreview)
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.glass-settings-dialog__preset {
block-size: 42px !important;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-block-start: 10px;
}
.glass-settings-dialog__appearance-option {
block-size: 32px !important;
inline-size: 100%;
@@ -233,7 +498,8 @@ onScopeDispose(cancelGlassPreview)
}
.glass-settings-dialog__appearance-option,
.glass-settings-dialog__quality-option {
.glass-settings-dialog__quality-option,
.glass-settings-dialog__preset-option {
border: 0 !important;
border-radius: 7px !important;
box-shadow: none !important;
@@ -247,8 +513,13 @@ onScopeDispose(cancelGlassPreview)
block-size: 32px !important;
}
.glass-settings-dialog__preset-option {
block-size: 32px !important;
}
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
.glass-settings-dialog__quality-option:deep(.v-btn--active) {
.glass-settings-dialog__quality-option:deep(.v-btn--active),
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
background-color: rgba(var(--v-theme-primary), 0.14) !important;
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
}

View File

@@ -3,11 +3,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
const slotStub = { template: '<div><slot /></div>' }
const toggleStub = {
props: ['modelValue'],
template: '<div :data-model-value="modelValue"><slot /></div>',
}
const sliderStub = {
emits: ['update:modelValue'],
name: 'VSlider',
props: ['disabled', 'modelValue'],
template:
'<input class="slider-stub" type="range" :data-disabled="String(disabled)" :value="modelValue" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
}
const mocks = vi.hoisted(() => ({
cancelGlassPreview: vi.fn(),
commitGlassPreview: vi.fn(),
previewGlassSettings: vi.fn(),
settings: {
value: {
glassAppearance: 'clear',
glassDeformationStrength: 50,
glassFlowStrength: 50,
glassPreset: 'natural',
glassQuality: 'css',
glassReflectionStrength: 50,
glassTranslationStrength: 50,
glassTransparencyStrength: 50,
},
},
}))
vi.mock('@/composables/useThemeCustomizer', () => ({
@@ -15,12 +38,7 @@ vi.mock('@/composables/useThemeCustomizer', () => ({
commitGlassPreview: mocks.commitGlassPreview,
previewGlassSettings: mocks.previewGlassSettings,
useThemeCustomizer: () => ({
settings: {
value: {
glassAppearance: 'clear',
glassQuality: 'css',
},
},
settings: mocks.settings,
}),
}))
@@ -28,31 +46,53 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vuetify', () => ({
useDisplay: () => ({ mdAndUp: { value: true } }),
}))
describe('GlassSettingsDialog', () => {
beforeEach(() => {
mocks.cancelGlassPreview.mockClear()
mocks.commitGlassPreview.mockClear()
mocks.previewGlassSettings.mockClear()
mocks.settings.value.glassAppearance = 'clear'
mocks.settings.value.glassDeformationStrength = 50
mocks.settings.value.glassFlowStrength = 50
mocks.settings.value.glassPreset = 'natural'
mocks.settings.value.glassQuality = 'css'
mocks.settings.value.glassReflectionStrength = 50
mocks.settings.value.glassTranslationStrength = 50
mocks.settings.value.glassTransparencyStrength = 50
})
it('cancels an active preview when the parent closes the dialog', async () => {
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: { VDialogCloseBtn: true },
stubs: {
VCard: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(2)
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
await wrapper.setProps({ modelValue: false })
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
})
it('resets the draft to the default glass settings without committing', async () => {
it('resets parameters to the current material, quality, and preset without committing', async () => {
mocks.settings.value.glassAppearance = 'frosted'
mocks.settings.value.glassPreset = 'liquid'
mocks.settings.value.glassQuality = 'high'
mocks.settings.value.glassDeformationStrength = 65
mocks.settings.value.glassFlowStrength = 61
mocks.settings.value.glassReflectionStrength = 58
mocks.settings.value.glassTranslationStrength = 57
mocks.settings.value.glassTransparencyStrength = 55
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
@@ -66,6 +106,7 @@ describe('GlassSettingsDialog', () => {
VDialog: slotStub,
VDialogCloseBtn: true,
VDivider: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
@@ -75,9 +116,143 @@ describe('GlassSettingsDialog', () => {
await resetButton.trigger('click')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
glassAppearance: 'clear',
glassQuality: 'balanced',
glassDeformationStrength: 82,
glassFlowStrength: 80,
glassPreset: 'liquid',
glassReflectionStrength: 44,
glassTranslationStrength: 54,
glassTransparencyStrength: 46,
})
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
it('keeps static reflection adjustable in standard quality', async () => {
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(2)
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
await sliders[1].setValue('86')
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
})
it('hides preset choices in standard quality', () => {
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
})
it('keeps the selected preset highlighted after slider adjustments', async () => {
mocks.settings.value.glassQuality = 'balanced'
mocks.settings.value.glassPreset = 'glide'
mocks.settings.value.glassTransparencyStrength = 61
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VBtn: slotStub,
VBtnToggle: toggleStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
const preset = wrapper.find('.glass-settings-dialog__preset')
const sliders = wrapper.findAll('.slider-stub')
expect(preset.attributes('data-model-value')).toBe('glide')
expect(wrapper.findAll('.glass-settings-dialog__preset-option')).toHaveLength(3)
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
await sliders[0].setValue('77')
expect(preset.attributes('data-model-value')).toBe('glide')
})
it('normalizes and previews all five independent slider values', async () => {
mocks.settings.value.glassQuality = 'balanced'
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(5)
expect(sliders.every(slider => slider.attributes('data-disabled') !== 'true')).toBe(true)
await sliders[0].setValue('91.4')
await sliders[1].setValue('73.6')
await sliders[2].setValue('84.2')
await sliders[3].setValue('68.7')
await sliders[4].setValue('62.2')
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassReflectionStrength: 74 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassTranslationStrength: 84 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassDeformationStrength: 69 })
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassFlowStrength: 62 })
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
})
it('keeps motion tuning available in high quality', () => {
mocks.settings.value.glassQuality = 'high'
const wrapper = shallowMount(GlassSettingsDialog, {
global: {
stubs: {
VCard: slotStub,
VCardText: slotStub,
VDialog: slotStub,
VDialogCloseBtn: true,
VSlider: sliderStub,
},
},
props: { modelValue: true },
})
const sliders = wrapper.findAll('.slider-stub')
expect(sliders).toHaveLength(5)
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
'theme.glassTransparencyStrength',
'theme.glassReflectionStrength',
'theme.glassTranslationStrength',
'theme.glassDeformationStrength',
'theme.glassFlowStrength',
])
})
})

View File

@@ -1,32 +1,113 @@
<script setup lang="ts">
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
import { useGlassOpticalRenderer } from '@/composables/useGlassOpticalRenderer'
import {
setGlassRendererState,
useGlassOpticalInteractionSource,
useGlassOpticalRenderer,
type GlassRendererState,
} from '@/composables/useGlassOpticalRenderer'
const props = defineProps<{
/** 当前玻璃材质,用于选择透明、色调或磨砂的光学参数。 */
appearance: ThemeCustomizerGlassAppearance
/** 用户选择的局部非均匀形变强度。 */
deformationStrength: number
/** 用户选择的轨迹、尾波与惯性强度。 */
flowStrength: number
/** 当前光学质量;标准档不会挂载该组件。 */
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
/** 用户选择的亮边、镜面高光与焦散强度。 */
reflectionStrength: number
/** 用户选择的真实壁纸可见度。 */
transparencyStrength: number
/** 用户选择的共享壁纸采样平移强度。 */
translationStrength: number
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
routeKey: string
/** 当前主题主色,用于同步色调材质的光学高光。 */
tintColor: string
/** 外层壁纸交叉淡化的时长shader 使用同一时钟混合双纹理。 */
transitionDuration: number
/** 外层壁纸交叉淡化的 performance timeline 起点。 */
transitionStartedAt: number
/** 与 CSS 背景保持一致的活动壁纸。 */
wallpaperUrl: string
/** 切换期保留的上一张壁纸;空值表示当前没有交叉淡化。 */
previousWallpaperUrl: string
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const { state } = useGlassOpticalRenderer({
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
const interactionSource = useGlassOpticalInteractionSource()
const fixedRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
canvas,
canvas: fixedCanvas,
deformationStrength: () => props.deformationStrength,
flowStrength: () => props.flowStrength,
interactionSource,
quality: () => props.quality,
reflectionStrength: () => props.reflectionStrength,
transparencyStrength: () => props.transparencyStrength,
translationStrength: () => props.translationStrength,
routeKey: () => props.routeKey,
tintColor: () => props.tintColor,
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
previousWallpaperUrl: () => props.previousWallpaperUrl,
surfaceSpace: 'fixed',
syncDocumentState: false,
})
const scrollRenderer = useGlassOpticalRenderer({
active: true,
appearance: () => props.appearance,
canvas: scrollCanvas,
deformationStrength: () => props.deformationStrength,
flowStrength: () => props.flowStrength,
interactionSource,
quality: () => props.quality,
reflectionStrength: () => props.reflectionStrength,
transparencyStrength: () => props.transparencyStrength,
translationStrength: () => props.translationStrength,
routeKey: () => props.routeKey,
tintColor: () => props.tintColor,
transitionDuration: () => props.transitionDuration,
transitionStartedAt: () => props.transitionStartedAt,
wallpaperUrl: () => props.wallpaperUrl,
previousWallpaperUrl: () => props.previousWallpaperUrl,
surfaceSpace: 'scroll',
syncDocumentState: false,
})
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'
setGlassRendererState(rendererState, state)
})
</script>
<template>
<canvas ref="canvas" class="glass-optical-layer" aria-hidden="true" :data-state="state" />
<canvas
ref="fixedCanvas"
class="glass-optical-layer glass-optical-layer--fixed"
aria-hidden="true"
data-presentation-space="fixed"
:data-state="fixedRenderer.state.value"
/>
<canvas
ref="scrollCanvas"
class="glass-optical-layer glass-optical-layer--scroll"
aria-hidden="true"
data-presentation-space="scroll"
:data-state="scrollRenderer.state.value"
/>
</template>

View File

@@ -12,6 +12,7 @@ import {
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { GLASS_OPTICAL_STRENGTH_DEFAULT } from '@/utils/glassOptics'
const emit = defineEmits<{
'close': []
@@ -140,7 +141,12 @@ const hasAppModeCustomization = computed(() => {
return (
settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.glassAppearance !== 'clear' ||
settings.value.glassDeformationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassFlowStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassQuality !== 'css' ||
settings.value.glassReflectionStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
settings.value.radius !== 'default' ||
settings.value.shadow !== '0' ||
settings.value.skin !== 'default' ||

View File

@@ -0,0 +1,52 @@
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
setGlassRendererState: vi.fn((state: { value: string }, value: string) => {
state.value = value
}),
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
rendererCalls.push(options)
return {
renderedFrames: ref(0),
state: ref('ready'),
}
}),
}))
describe('GlassOpticalLayer', () => {
it('uses exactly two visible presentation contexts with one interaction source', () => {
rendererCalls.length = 0
const wrapper = shallowMount(GlassOpticalLayer, {
props: {
appearance: 'clear',
deformationStrength: 50,
flowStrength: 50,
previousWallpaperUrl: '',
quality: 'balanced',
reflectionStrength: 50,
routeKey: '/dashboard',
tintColor: '#8D51F9',
transitionDuration: 1500,
transitionStartedAt: 0,
translationStrength: 50,
transparencyStrength: 50,
wallpaperUrl: '/wallpaper.jpg',
},
})
const canvases = wrapper.findAll('canvas')
expect(canvases).toHaveLength(2)
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
})
})

View File

@@ -7,6 +7,7 @@ import {
} from '@/composables/useGlassOpticalRenderer'
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
vi.mock('three', async importOriginal => {
const actual = await importOriginal<typeof import('three')>()
@@ -26,11 +27,15 @@ vi.mock('three', async importOriginal => {
return Promise.resolve()
}
clear() {}
dispose() {}
forceContextLoss() {}
render() {}
setClearColor() {}
setPixelRatio() {}
setRenderTarget() {}
setScissor() {}
setScissorTest() {}
setSize() {}
},
}
@@ -63,6 +68,59 @@ function appendOpticalSurface(className: string, bounds: Pick<DOMRect, 'height'
return surface
}
/** 为元素提供可由 renderer 读取的稳定视口边界。 */
function setOpticalSurfaceBounds(element: HTMLElement, bounds: Pick<DOMRect, 'height' | 'width' | 'x' | 'y'>) {
element.getBoundingClientRect = () =>
({
...bounds,
bottom: bounds.y + bounds.height,
left: bounds.x,
right: bounds.x + bounds.width,
top: bounds.y,
toJSON: () => ({}),
}) as DOMRect
}
function stubMediaPreferences({ coarsePointer = false, reducedMotion = false, reducedTransparency = false } = {}) {
vi.stubGlobal(
'matchMedia',
vi.fn((query: string) => ({
addEventListener: vi.fn(),
matches:
(query === '(pointer: coarse)' && coarsePointer) ||
(query === '(prefers-reduced-motion: reduce)' && reducedMotion) ||
(query === '(prefers-reduced-transparency: reduce)' && reducedTransparency),
media: query,
removeEventListener: vi.fn(),
})),
)
}
function createTouchList(points: Array<{ clientX: number; clientY: number; identifier: number }>) {
const touches = points.map(point => point as Touch)
return Object.assign(touches, {
item(index: number) {
return touches[index] ?? null
},
}) as unknown as TouchList
}
function dispatchTouchEvent(
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
changedTouches = touches,
) {
const event = new Event(type) as TouchEvent
Object.defineProperties(event, {
changedTouches: { value: createTouchList(changedTouches) },
touches: { value: createTouchList(touches) },
})
window.dispatchEvent(event)
return event
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
vi.stubGlobal('WebGLRenderingContext', class {})
@@ -70,6 +128,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
document.body.replaceChildren()
@@ -79,14 +138,14 @@ afterEach(() => {
describe('glass optical surface discovery', () => {
it('detects a target surface added directly', () => {
const surface = document.createElement('section')
surface.className = 'dashboard-grid-item-content'
surface.dataset.glassOpticalSurface = ''
expect(containsGlassOpticalSurface(surface)).toBe(true)
})
it('detects a target surface inside an asynchronously added subtree', () => {
const wrapper = document.createElement('div')
wrapper.innerHTML = '<div><section class="dashboard-grid-item-content"></section></div>'
wrapper.innerHTML = '<div><section data-glass-optical-surface></section></div>'
expect(containsGlassOpticalSurface(wrapper)).toBe(true)
expect(containsGlassOpticalSurface(document.createElement('div'))).toBe(false)
@@ -115,7 +174,8 @@ describe('glass optical surface discovery', () => {
it('excludes the navbar from mobile clear optical surfaces', () => {
appendOpticalSurface('layout-navbar', { height: 64, width: 390, x: 0, y: 0 })
appendOpticalSurface('dashboard-grid-item-content', { height: 200, width: 350, x: 20, y: 120 })
const content = appendOpticalSurface('dashboard-content', { height: 200, width: 350, x: 20, y: 120 })
content.dataset.glassOpticalSurface = ''
const rects = collectGlassOpticalRects(390, 800, 'clear')
@@ -139,6 +199,61 @@ describe('glass optical surface discovery', () => {
])
})
it('uses the actual dashboard card boundary and all four corner radii', () => {
const shell = document.createElement('section')
shell.className = 'dashboard-grid-item-content'
shell.innerHTML = `
<div class="dashboard-grid-auto-size">
<div class="dashboard-grid-content-measure">
<article class="v-card"></article>
</div>
</div>
`
const card = shell.querySelector<HTMLElement>('.v-card')!
card.style.borderTopLeftRadius = '24px'
card.style.borderTopRightRadius = '20px'
card.style.borderBottomRightRadius = '16px'
card.style.borderBottomLeftRadius = '12px'
setOpticalSurfaceBounds(shell, { height: 220, width: 360, x: 20, y: 100 })
setOpticalSurfaceBounds(card, { height: 200, width: 340, x: 30, y: 110 })
document.body.append(shell)
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
expect.objectContaining({
height: 200,
radii: [24, 20, 16, 12],
width: 340,
x: 30,
y: 110,
}),
])
})
it('discovers explicit optical surfaces without component-specific selectors', () => {
const surface = appendOpticalSurface('custom-surface', { height: 180, width: 300, x: 40, y: 120 })
surface.dataset.glassOpticalSurface = ''
surface.style.borderTopLeftRadius = '18px'
surface.style.borderTopRightRadius = '18px'
surface.style.borderBottomRightRadius = '18px'
surface.style.borderBottomLeftRadius = '18px'
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
expect.objectContaining({ height: 180, radii: [18, 18, 18, 18], width: 300, x: 40, y: 120 }),
])
})
it('discovers the shared interactive card contract used across routes', () => {
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
surface.style.borderTopLeftRadius = '20px'
surface.style.borderTopRightRadius = '20px'
surface.style.borderBottomRightRadius = '20px'
surface.style.borderBottomLeftRadius = '20px'
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([
expect.objectContaining({ height: 220, radii: [20, 20, 20, 20], width: 150, x: 24, y: 96 }),
])
})
it('recovers after consecutive WebGL context loss cycles', async () => {
const canvas = document.createElement('canvas')
const scope = effectScope()
@@ -177,6 +292,7 @@ describe('glass optical surface discovery', () => {
const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => ++frameId)
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame')
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const resizeDisconnect = vi.spyOn(ResizeObserverMock.prototype, 'disconnect')
const mutationDisconnect = vi.spyOn(MutationObserver.prototype, 'disconnect')
@@ -200,16 +316,24 @@ describe('glass optical surface discovery', () => {
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(requestFrame).toHaveBeenCalled()
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeFirstRelease = renderTargetDispose.mock.calls.length
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(1)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeFirstRelease + 2)
expect(contextLoss).toHaveBeenCalledTimes(1)
expect(resizeDisconnect).toHaveBeenCalledTimes(1)
expect(mutationDisconnect).toHaveBeenCalledTimes(1)
@@ -224,16 +348,24 @@ describe('glass optical surface discovery', () => {
expect(renderer?.state.value).toBe('ready')
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchstart')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchmove')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchend')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'touchcancel')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'scroll')).toBe(1)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(1)
expect(countListenerAdds(addWindowListener.mock.calls, 'focus')).toBe(0)
expect(countListenerAdds(addWindowListener.mock.calls, 'pageshow')).toBe(0)
expect(countListenerAdds(addDocumentListener.mock.calls, 'visibilitychange')).toBe(0)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
const renderTargetDisposalsBeforeSecondRelease = renderTargetDispose.mock.calls.length
active.value = false
await nextTick()
expect(rendererDispose).toHaveBeenCalledTimes(2)
expect(renderTargetDispose).toHaveBeenCalledTimes(renderTargetDisposalsBeforeSecondRelease + 2)
expect(contextLoss).toHaveBeenCalledTimes(2)
expect(resizeDisconnect).toHaveBeenCalledTimes(2)
expect(mutationDisconnect).toHaveBeenCalledTimes(2)
@@ -241,4 +373,680 @@ describe('glass optical surface discovery', () => {
scope.stop()
})
it('reuses the renderer context while switching quality profiles', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const quality = ref<'balanced' | 'high'>('high')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality,
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
const renderTargetDispose = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
quality.value = 'balanced'
await nextTick()
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(rendererDispose).not.toHaveBeenCalled()
expect(contextLoss).not.toHaveBeenCalled()
expect(renderTargetDispose).toHaveBeenCalledTimes(2)
quality.value = 'high'
await nextTick()
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
expect(rendererDispose).not.toHaveBeenCalled()
expect(contextLoss).not.toHaveBeenCalled()
expect(renderTargetDispose).toHaveBeenCalledTimes(4)
scope.stop()
})
it('keeps old and new wallpaper textures in the same renderer during the shared transition', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg')
const previousWallpaperUrl = ref('')
const transitionStartedAt = ref(0)
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('frosted'),
canvas: ref(canvas),
quality: ref('high'),
previousWallpaperUrl,
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
transitionDuration: ref(1500),
transitionStartedAt,
wallpaperUrl,
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const replacementTexture = new three.Texture<HTMLImageElement>()
replacementTexture.image = {
height: 1,
naturalHeight: 1,
naturalWidth: 1,
width: 1,
} as HTMLImageElement
let resolveTexture!: (texture: typeof replacementTexture) => void
vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockReturnValueOnce(
new Promise<typeof replacementTexture>(resolve => {
resolveTexture = resolve
}),
)
previousWallpaperUrl.value = wallpaperUrl.value
transitionStartedAt.value = performance.now()
wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg'
await nextTick()
expect(renderer?.state.value).toBe('ready')
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBe('true'))
resolveTexture(replacementTexture)
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined())
await vi.waitFor(() => expect(render).toHaveBeenCalled())
expect(renderer?.state.value).toBe('ready')
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uPreviousTexture: { value: unknown }
uTexture: { value: unknown }
uTextureMix: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
expect(uniforms.uPreviousTexture.value).not.toBe(uniforms.uTexture.value)
expect(uniforms.uTextureMix.value).toBeGreaterThanOrEqual(0)
expect(uniforms.uTextureMix.value).toBeLessThan(1)
scope.stop()
})
it('keeps the active texture and ready state when a replacement wallpaper fails', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
const wallpaperUrl = ref('https://example.com/wallpaper-1.jpg')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl,
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const rendererDispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(three.TextureLoader.prototype, 'loadAsync').mockRejectedValueOnce(new Error('replacement failed'))
wallpaperUrl.value = 'https://example.com/wallpaper-2.jpg'
await nextTick()
await vi.waitFor(() => expect(document.documentElement.dataset.glassWallpaperLoading).toBeUndefined())
expect(renderer?.state.value).toBe('ready')
expect(rendererDispose).not.toHaveBeenCalled()
scope.stop()
})
it('pauses briefly hidden renderers and resumes a stable frame without disposing resources', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
let visibilityState: DocumentVisibilityState = 'visible'
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
vi.useFakeTimers()
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS - 1)
expect(dispose).not.toHaveBeenCalled()
visibilityState = 'visible'
window.dispatchEvent(new Event('focus'))
await nextTick()
await Promise.resolve()
expect(dispose).not.toHaveBeenCalled()
expect(render).toHaveBeenCalled()
scope.stop()
})
it('releases renderer resources after the long background timeout', async () => {
const three = await import('three')
const canvas = document.createElement('canvas')
let visibilityState: DocumentVisibilityState = 'visible'
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
vi.useFakeTimers()
visibilityState = 'hidden'
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
expect(dispose).toHaveBeenCalledTimes(1)
scope.stop()
})
it('uses the CSS fallback when reduced transparency is active', async () => {
stubMediaPreferences({ reducedTransparency: true })
const canvas = document.createElement('canvas')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/dashboard'),
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback'))
expect(document.documentElement.dataset.glassRendererState).toBe('fallback')
scope.stop()
})
it('tracks the actual passive touch coordinate without restoring press magnification', async () => {
stubMediaPreferences({ coarsePointer: true })
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<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)
})
const surface = appendOpticalSurface('app-hover-lift-card', { height: 500, width: 350, x: 20, y: 100 })
surface.style.borderRadius = '20px'
const canvas = document.createElement('canvas')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('frosted'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/recommend'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now()))
}
render.mockClear()
dispatchTouchEvent('touchstart', [{ clientX: 80, clientY: 180, identifier: 7 }])
const startFrames = [...callbacks.values()]
callbacks.clear()
startFrames.forEach(callback => callback(performance.now()))
render.mockClear()
const moveEvent = dispatchTouchEvent('touchmove', [{ clientX: 180, clientY: 320, identifier: 7 }])
const interactionFrames = [...callbacks.values()]
callbacks.clear()
interactionFrames.forEach(callback => callback(moveEvent.timeStamp + 16))
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uPointer: { value: { x: number; y: number } }
uTrail: { value: Array<{ x: number; y: number; z: number }> }
}
}
}>
}
const pointer = scene.children[0].material.uniforms.uPointer.value
const trail = scene.children[0].material.uniforms.uTrail.value
expect(pointer.x).toBeCloseTo(180 / 390)
expect(pointer.y).toBeCloseTo(1 - 320 / 844)
expect(trail[0]).toMatchObject({ z: 1 })
expect(trail[0].x).toBeCloseTo(180 / 390)
expect(trail[0].y).toBeCloseTo(1 - 320 / 844)
expect(trail[1]).toMatchObject({ z: 0.72 })
expect(trail[1].x).toBeCloseTo(80 / 390)
expect(trail[1].y).toBeCloseTo(1 - 180 / 844)
dispatchTouchEvent('touchend', [], [{ clientX: 180, clientY: 320, identifier: 7 }])
scope.stop()
})
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
stubMediaPreferences({ coarsePointer: true })
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(844)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<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)
})
const surface = appendOpticalSurface('test-surface', { height: 500, width: 350, x: 20, y: 100 })
surface.dataset.glassOpticalSurface = ''
const canvas = document.createElement('canvas')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('frosted'),
canvas: ref(canvas),
quality: ref('high'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now()))
}
expect(callbacks.size).toBe(0)
vi.spyOn(window, 'scrollY', 'get').mockReturnValue(120)
setOpticalSurfaceBounds(surface, { height: 500, width: 350, x: 20, y: -20 })
window.dispatchEvent(new Event('scroll'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now() + 160 + pass * 16))
}
const settledScene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uMotion: { value: number }
uRects: { value: Array<{ y: number }> }
uScrollOffset: { value: { y: number } }
uTrail: { value: Array<{ z: number }> }
}
}
}>
}
const settledUniforms = settledScene.children[0].material.uniforms
expect(settledUniforms.uMotion.value).toBe(0)
expect(settledUniforms.uTrail.value.every(trail => trail.z === 0)).toBe(true)
expect(settledUniforms.uRects.value[0].y).toBeCloseTo(1 - (100 + 500) / 844)
expect(settledUniforms.uScrollOffset.value.y).toBe(120)
expect(callbacks.size).toBe(0)
scope.stop()
})
it('keeps local material response stable while moving from card A through a gap to card B', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(700)
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<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)
})
const surfaces = [
{ x: 100, name: 'A' },
{ x: 400, name: 'B' },
]
surfaces.forEach(({ name, x }) => {
const surface = appendOpticalSurface('app-hover-lift-card', {
height: 180,
width: 180,
x,
y: 100,
})
surface.dataset.testSurface = name
})
const canvas = document.createElement('canvas')
const deformationStrength = ref(50)
const flowStrength = ref(50)
const reflectionStrength = ref(50)
const translationStrength = ref(50)
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
deformationStrength,
flowStrength,
quality: ref('balanced'),
reflectionStrength,
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
translationStrength,
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now()))
}
render.mockClear()
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
const [cardAFrame] = callbacks.values()
callbacks.clear()
cardAFrame(performance.now() + 16)
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uDeformationStrength: { value: number }
uFlowStrength: { value: number }
uMaxRefractionPixels: { value: number }
uMotionExpansion: { value: number }
uRects: { value: Array<{ x: number }> }
uReflectionStrength: { value: number }
uTransparency: { value: number }
uTranslationStrength: { value: number }
uSurfaceWeights: { value: number[] }
}
fragmentShader: string
}
}>
}
const uniforms = scene.children[0].material.uniforms
const cardAX = 100 / 1200
const cardBX = 400 / 1200
expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true)
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 330, clientY: 160 }))
expect(uniforms.uRects.value.some(rect => Math.abs(rect.x - cardAX) < 0.001)).toBe(true)
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 460, clientY: 160 }))
const cardAIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardAX) < 0.001)
const cardBIndex = uniforms.uRects.value.findIndex(rect => Math.abs(rect.x - cardBX) < 0.001)
expect(cardAIndex).toBeGreaterThanOrEqual(0)
expect(cardBIndex).toBeGreaterThanOrEqual(0)
expect(uniforms.uSurfaceWeights.value[cardAIndex]).toBe(1)
expect(uniforms.uSurfaceWeights.value[cardBIndex]).toBe(1)
expect(uniforms.uTranslationStrength.value).toBe(1)
expect(uniforms.uDeformationStrength.value).toBe(1)
expect(uniforms.uFlowStrength.value).toBe(1)
expect(uniforms.uMotionExpansion.value).toBe(0)
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
expect(uniforms.uReflectionStrength.value).toBe(1)
expect(uniforms.uTransparency.value).toBeGreaterThan(0.6)
translationStrength.value = 100
deformationStrength.value = 100
flowStrength.value = 100
reflectionStrength.value = 80
await nextTick()
expect(uniforms.uTranslationStrength.value).toBeCloseTo(1.7)
expect(uniforms.uDeformationStrength.value).toBeCloseTo(1.55)
expect(uniforms.uFlowStrength.value).toBeCloseTo(1.45)
expect(uniforms.uMotionExpansion.value).toBe(1)
expect(uniforms.uMaxRefractionPixels.value).toBe(6)
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
expect(scene.children[0].material.fragmentShader).not.toContain('clamp(uMotion +')
expect(scene.children[0].material.fragmentShader).toContain(
'materialEnergy = max(materialEnergy, liquidEnergy * rectMask)',
)
expect(scene.children[0].material.fragmentShader).toContain('softLimitDynamicRefraction')
expect(scene.children[0].material.fragmentShader).toContain('getContentProtection')
expect(scene.children[0].material.fragmentShader).toContain('sampleHighQualityDiffuse')
expect(scene.children[0].material.fragmentShader).toContain('singleSpecular')
expect(scene.children[0].material.fragmentShader).not.toContain('stableSample')
expect(scene.children[0].material.fragmentShader).not.toContain('broadReflection')
expect(scene.children[0].material.fragmentShader).toContain('flowSurfaceDetail * 0.38')
expect(scene.children[0].material.fragmentShader).not.toContain('pointerCurvature')
expect(scene.children[0].material.fragmentShader).not.toContain('wakeCurvature')
expect(scene.children[0].material.fragmentShader).not.toContain('surfaceCurvature')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uMotionExpansion')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uFlowStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTransparency')
expect(scene.children[0].material.fragmentShader).toContain('uMotion *')
expect(scene.children[0].material.fragmentShader).toContain('uTranslationStrength')
expect(scene.children[0].material.fragmentShader).toContain('vec2 lightDirection = normalize(vec2(-0.68, 0.74))')
expect(scene.children[0].material.fragmentShader).toContain('float highlightBudget')
expect(scene.children[0].material.fragmentShader).toContain('float absorption')
expect(scene.children[0].material.fragmentShader).toContain(
'causticHighlightMix * uReflectionStrength * highlightBudget',
)
expect(scene.children[0].material.fragmentShader).toContain('mix(0.78, 0.94, uTransparency)')
expect(scene.children[0].material.fragmentShader).not.toContain('mix(0.035, 0.4')
expect(scene.children[0].material.fragmentShader).not.toContain('sin(')
scope.stop()
})
it('settles pointer feedback without leaving a continuous animation frame', async () => {
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<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)
})
const surface = appendOpticalSurface('test-surface', { height: 240, width: 320, x: 20, y: 80 })
surface.dataset.glassOpticalSurface = ''
const canvas = document.createElement('canvas')
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(canvas),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now()))
}
expect(callbacks.size).toBe(0)
render.mockClear()
const pointerMove = new MouseEvent('pointermove', { clientX: 160, clientY: 160 })
window.dispatchEvent(pointerMove)
expect(callbacks.size).toBe(1)
const [firstInteractionFrame] = callbacks.values()
callbacks.clear()
firstInteractionFrame(pointerMove.timeStamp + 100)
const firstScene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
}>
}
const firstVelocity = firstScene.children[0].material.uniforms.uPointerVelocity.value
const stableDirection = { x: firstVelocity.x, y: firstVelocity.y }
const [secondInteractionFrame] = callbacks.values()
callbacks.clear()
secondInteractionFrame(pointerMove.timeStamp + 300)
const secondScene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
}>
}
expect(secondScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject(stableDirection)
const [finalInteractionFrame] = callbacks.values()
callbacks.clear()
finalInteractionFrame(pointerMove.timeStamp + 500)
const finalScene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: { uniforms: { uPointerVelocity: { value: { x: number; y: number } } } }
}>
}
expect(callbacks.size).toBe(0)
expect(finalScene.children[0].material.uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 })
scope.stop()
})
it('keeps immediate translation and deformation at zero flow without scheduling inertia', async () => {
const three = await import('three')
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
const callbacks = new Map<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: 240, width: 320, x: 20, y: 80 })
const scope = effectScope()
const renderer = scope.run(() =>
useGlassOpticalRenderer({
active: ref(true),
appearance: ref('clear'),
canvas: ref(document.createElement('canvas')),
deformationStrength: ref(70),
flowStrength: ref(0),
quality: ref('balanced'),
routeKey: ref('/dashboard'),
surfaceSpace: 'scroll',
tintColor: ref('#8D51F9'),
translationStrength: ref(70),
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
}),
)
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
for (let pass = 0; pass < 3 && callbacks.size > 0; pass += 1) {
const scheduledCallbacks = [...callbacks.values()]
callbacks.clear()
scheduledCallbacks.forEach(callback => callback(performance.now()))
}
render.mockClear()
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
expect(callbacks.size).toBe(1)
const [frame] = callbacks.values()
callbacks.clear()
frame(performance.now() + 16)
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
children: Array<{
material: {
uniforms: {
uDeformationStrength: { value: number }
uFlowStrength: { value: number }
uMotion: { value: number }
uTranslationStrength: { value: number }
}
}
}>
}
const uniforms = scene.children[0].material.uniforms
expect(uniforms.uMotion.value).toBe(1)
expect(uniforms.uTranslationStrength.value).toBeGreaterThan(1)
expect(uniforms.uDeformationStrength.value).toBeGreaterThan(1)
expect(uniforms.uFlowStrength.value).toBe(0)
expect(callbacks.size).toBe(0)
scope.stop()
})
})

View File

@@ -6,6 +6,7 @@ import {
previewGlassSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_STORAGE_KEY,
useEffectiveGlassSettings,
} from '@/composables/useThemeCustomizer'
import { beforeEach, describe, expect, it } from 'vitest'
@@ -17,11 +18,17 @@ describe('useThemeCustomizer glass settings', () => {
localStorage.clear()
})
it('uses clear appearance and balanced quality by default', () => {
it('uses the checkpoint appearance, quality, and strength values by default', () => {
const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear')
expect(settings.glassDeformationStrength).toBe(50)
expect(settings.glassFlowStrength).toBe(50)
expect(settings.glassPreset).toBe('natural')
expect(settings.glassQuality).toBe('balanced')
expect(settings.glassReflectionStrength).toBe(50)
expect(settings.glassTranslationStrength).toBe(50)
expect(settings.glassTransparencyStrength).toBe(50)
})
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
@@ -39,15 +46,47 @@ describe('useThemeCustomizer glass settings', () => {
it('falls back when stored glass settings are invalid', () => {
localStorage.setItem(
THEME_CUSTOMIZER_STORAGE_KEY,
JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }),
JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }),
)
const settings = readThemeCustomizerSettings()
expect(settings.glassAppearance).toBe('clear')
expect(settings.glassPreset).toBe('natural')
expect(settings.glassQuality).toBe('balanced')
})
it('rounds and clamps persisted optical strength values', () => {
localStorage.setItem(
THEME_CUSTOMIZER_STORAGE_KEY,
JSON.stringify({
glassDeformationStrength: -12,
glassFlowStrength: 42.6,
glassReflectionStrength: 140.6,
glassTranslationStrength: 101,
glassTransparencyStrength: 83.7,
}),
)
expect(readThemeCustomizerSettings()).toMatchObject({
glassDeformationStrength: 0,
glassFlowStrength: 43,
glassReflectionStrength: 100,
glassTranslationStrength: 100,
glassTransparencyStrength: 84,
})
})
it('migrates the legacy motion value into translation, deformation, and flow', () => {
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassMotionStrength: 67 }))
expect(readThemeCustomizerSettings()).toMatchObject({
glassDeformationStrength: 67,
glassFlowStrength: 67,
glassTranslationStrength: 67,
})
})
it('syncs glass settings to the document roots', () => {
const settings = readThemeCustomizerSettings()
@@ -61,6 +100,10 @@ describe('useThemeCustomizer glass settings', () => {
expect(document.documentElement.dataset.glassQuality).toBe('high')
expect(document.body.dataset.glassAppearance).toBe('tinted')
expect(document.body.dataset.glassQuality).toBe('high')
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.5')
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.5')
expect(document.documentElement.style.getPropertyValue('--glass-transparency')).toBe('0.5')
expect(document.body.style.getPropertyValue('--glass-transparency')).toBe('0.5')
})
it('previews glass settings without persisting them', () => {
@@ -76,11 +119,29 @@ describe('useThemeCustomizer glass settings', () => {
it('commits the latest glass preview as one persisted state', () => {
previewGlassSettings({ glassAppearance: 'tinted' })
previewGlassSettings({ glassAppearance: 'clear' })
previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' })
previewGlassSettings({
glassAppearance: 'tinted',
glassDeformationStrength: 74,
glassFlowStrength: 63,
glassPreset: 'glide',
glassQuality: 'css',
glassReflectionStrength: 81,
glassTranslationStrength: 69,
glassTransparencyStrength: 80,
})
commitGlassPreview()
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' })
expect(readThemeCustomizerSettings()).toMatchObject({
glassAppearance: 'tinted',
glassDeformationStrength: 74,
glassFlowStrength: 63,
glassPreset: 'glide',
glassQuality: 'css',
glassReflectionStrength: 81,
glassTranslationStrength: 69,
glassTransparencyStrength: 80,
})
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
})
@@ -96,12 +157,42 @@ describe('useThemeCustomizer glass settings', () => {
})
it('restores persisted glass settings when a preview is cancelled', () => {
persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' })
previewGlassSettings({ glassAppearance: 'clear' })
const effective = useEffectiveGlassSettings()
persistPartialThemeCustomizerSettings({
glassAppearance: 'tinted',
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassReflectionStrength: 66,
glassTranslationStrength: 46,
glassTransparencyStrength: 72,
})
previewGlassSettings({
glassAppearance: 'clear',
glassDeformationStrength: 90,
glassFlowStrength: 88,
glassReflectionStrength: 12,
glassTranslationStrength: 86,
glassTransparencyStrength: 94,
})
expect(effective.value).toMatchObject({
glassAppearance: 'clear',
glassDeformationStrength: 90,
glassFlowStrength: 88,
glassReflectionStrength: 12,
glassTranslationStrength: 86,
glassTransparencyStrength: 94,
})
cancelGlassPreview()
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted')
expect(effective.value).toMatchObject({
glassAppearance: 'tinted',
glassDeformationStrength: 42,
glassFlowStrength: 44,
glassReflectionStrength: 66,
glassTranslationStrength: 46,
glassTransparencyStrength: 72,
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,13 @@ import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { saveLocalTheme } from '@/@core/utils/theme'
import vuetify from '@/plugins/vuetify'
import {
GLASS_OPTICAL_STRENGTH_DEFAULT,
GLASS_OPTICAL_STRENGTH_MAX,
getGlassOpticalPresetParameters,
normalizeGlassOpticalStrength,
type GlassOpticalPreset,
} from '@/utils/glassOptics'
import { themeManager } from '@/utils/themeManager'
import { syncThemeFavicon } from '@/utils/themePalette'
@@ -64,8 +71,20 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
export interface ThemeCustomizerSettings {
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
glassAppearance: ThemeCustomizerGlassAppearance
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
glassDeformationStrength: number
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
glassFlowStrength: number
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
glassPreset: GlassOpticalPreset
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
glassQuality: ThemeCustomizerGlassQuality
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
glassReflectionStrength: number
/** 共享壁纸在表面内的统一采样平移强度,范围 0 到 100。 */
glassTranslationStrength: number
/** 玻璃材质释放真实壁纸的程度,范围 0 到 100。 */
glassTransparencyStrength: number
/** 桌面导航布局。 */
layout: ThemeCustomizerLayout
/** 主题强调色,也是色调玻璃的颜色来源。 */
@@ -86,6 +105,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
@@ -123,9 +143,17 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
/** 生成与当前主题偏好一致的定制器默认设置。 */
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
return {
glassAppearance: 'clear',
glassDeformationStrength: glassParameters.deformation,
glassFlowStrength: glassParameters.flow,
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: glassParameters.reflection,
glassTranslationStrength: glassParameters.translation,
glassTransparencyStrength: glassParameters.transparency,
layout: 'vertical',
primaryColor: defaultPrimaryColor,
radius: 'default',
@@ -136,6 +164,19 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
}
}
type NormalizableThemeCustomizerSettings = Partial<ThemeCustomizerSettings> & {
/** 旧版单一动态强度仅用于一次性迁移到三个独立维度。 */
glassMotionStrength?: unknown
}
/** 新字段缺失时继承旧版动态强度,避免升级后无声重置用户手感。 */
function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fallback: number) {
if (typeof value === 'number' && Number.isFinite(value)) return normalizeGlassOpticalStrength(value)
if (typeof legacyValue === 'number' && Number.isFinite(legacyValue)) return normalizeGlassOpticalStrength(legacyValue)
return fallback
}
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
@@ -145,7 +186,7 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
}
/** 规范化持久化的主题定制设置并迁移旧值。 */
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
function normalizeThemeCustomizerSettings(settings: NormalizableThemeCustomizerSettings): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
@@ -155,9 +196,29 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
: fallback.glassAppearance,
glassDeformationStrength: normalizeMigratedGlassStrength(
settings.glassDeformationStrength,
settings.glassMotionStrength,
fallback.glassDeformationStrength,
),
glassFlowStrength: normalizeMigratedGlassStrength(
settings.glassFlowStrength,
settings.glassMotionStrength,
fallback.glassFlowStrength,
),
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
? (settings.glassPreset as GlassOpticalPreset)
: fallback.glassPreset,
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
? (settings.glassQuality as ThemeCustomizerGlassQuality)
: fallback.glassQuality,
glassReflectionStrength: normalizeGlassOpticalStrength(settings.glassReflectionStrength),
glassTranslationStrength: normalizeMigratedGlassStrength(
settings.glassTranslationStrength,
settings.glassMotionStrength,
fallback.glassTranslationStrength,
),
glassTransparencyStrength: normalizeGlassOpticalStrength(settings.glassTransparencyStrength),
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
@@ -183,11 +244,7 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
try {
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
const parsed = stored ? JSON.parse(stored) : {}
return normalizeThemeCustomizerSettings({
...fallback,
...parsed,
theme: readStoredThemePreference(),
})
return normalizeThemeCustomizerSettings({ ...parsed, theme: readStoredThemePreference() })
} catch (error) {
console.warn('读取主题定制设置失败,已使用默认设置:', error)
@@ -197,13 +254,34 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
const glassPreviewState = ref<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'> | null>(null)
type ThemeCustomizerGlassSettings = Pick<
ThemeCustomizerSettings,
| 'glassAppearance'
| 'glassDeformationStrength'
| 'glassFlowStrength'
| 'glassPreset'
| 'glassQuality'
| 'glassReflectionStrength'
| 'glassTranslationStrength'
| 'glassTransparencyStrength'
>
const glassPreviewState = ref<ThemeCustomizerGlassSettings | null>(null)
const effectiveGlassSettings = computed(() => ({
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
glassDeformationStrength:
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
glassReflectionStrength:
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
glassTranslationStrength:
glassPreviewState.value?.glassTranslationStrength ?? settingsState.value.glassTranslationStrength,
glassTransparencyStrength:
glassPreviewState.value?.glassTransparencyStrength ?? settingsState.value.glassTransparencyStrength,
}))
/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */
/** 提供当前实际生效的玻璃设置;临时预览优先于已持久化设置。 */
export function useEffectiveGlassSettings() {
return readonly(effectiveGlassSettings)
}
@@ -259,13 +337,29 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
export function applyThemeCustomizerRootSettings(
settings: Pick<
ThemeCustomizerSettings,
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
| 'glassAppearance'
| 'glassQuality'
| 'glassReflectionStrength'
| 'glassTransparencyStrength'
| 'layout'
| 'radius'
| 'semiDarkMenu'
| 'shadow'
| 'skin'
>,
) {
if (!isBrowser()) return
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
document.documentElement.style.setProperty(
'--glass-reflection',
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.documentElement.style.setProperty(
'--glass-transparency',
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-radius', settings.radius)
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -273,6 +367,14 @@ export function applyThemeCustomizerRootSettings(
document.documentElement.setAttribute('data-theme-skin', settings.skin)
document.body.setAttribute('data-glass-appearance', settings.glassAppearance)
document.body.setAttribute('data-glass-quality', settings.glassQuality)
document.body.style.setProperty(
'--glass-reflection',
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.body.style.setProperty(
'--glass-transparency',
String(normalizeGlassOpticalStrength(settings.glassTransparencyStrength) / GLASS_OPTICAL_STRENGTH_MAX),
)
document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-radius', settings.radius)
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
@@ -340,10 +442,8 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
return nextSettings
}
/** 临时应用玻璃外观,不写入存储或广播持久化变更。 */
export function previewGlassSettings(
patch: Partial<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'>>,
) {
/** 临时应用玻璃设置,不写入存储或广播持久化变更。 */
export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings>) {
const previewSettings = normalizeThemeCustomizerSettings({
...settingsState.value,
...glassPreviewState.value,
@@ -352,7 +452,13 @@ export function previewGlassSettings(
glassPreviewState.value = {
glassAppearance: previewSettings.glassAppearance,
glassDeformationStrength: previewSettings.glassDeformationStrength,
glassFlowStrength: previewSettings.glassFlowStrength,
glassPreset: previewSettings.glassPreset,
glassQuality: previewSettings.glassQuality,
glassReflectionStrength: previewSettings.glassReflectionStrength,
glassTranslationStrength: previewSettings.glassTranslationStrength,
glassTransparencyStrength: previewSettings.glassTransparencyStrength,
}
applyThemeCustomizerRootSettings({
...settingsState.value,
@@ -387,7 +493,13 @@ export function cancelGlassPreview() {
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const defaults = normalizeThemeCustomizerSettings({
glassAppearance: 'clear',
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
layout: 'vertical',
primaryColor: defaultPrimaryColor,
radius: 'default',
@@ -399,7 +511,13 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
return (
settings.glassAppearance === defaults.glassAppearance &&
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
settings.glassFlowStrength === defaults.glassFlowStrength &&
settings.glassPreset === defaults.glassPreset &&
settings.glassQuality === defaults.glassQuality &&
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
settings.glassTranslationStrength === defaults.glassTranslationStrength &&
settings.glassTransparencyStrength === defaults.glassTransparencyStrength &&
settings.layout === defaults.layout &&
settings.primaryColor === defaults.primaryColor &&
settings.radius === defaults.radius &&
@@ -449,11 +567,41 @@ export function useThemeCustomizer() {
return updateSettings({ glassAppearance })
}
/** 更新玻璃局部非均匀形变强度。 */
function setGlassDeformationStrength(glassDeformationStrength: number) {
return updateSettings({ glassDeformationStrength })
}
/** 更新玻璃轨迹、尾波与惯性强度。 */
function setGlassFlowStrength(glassFlowStrength: number) {
return updateSettings({ glassFlowStrength })
}
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
function setGlassPreset(glassPreset: GlassOpticalPreset) {
return updateSettings({ glassPreset })
}
/** 更新玻璃主题渲染质量档位。 */
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
return updateSettings({ glassQuality })
}
/** 更新玻璃表面反射亮度。 */
function setGlassReflectionStrength(glassReflectionStrength: number) {
return updateSettings({ glassReflectionStrength })
}
/** 更新玻璃统一采样平移强度。 */
function setGlassTranslationStrength(glassTranslationStrength: number) {
return updateSettings({ glassTranslationStrength })
}
/** 更新玻璃材质的真实壁纸可见度。 */
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
return updateSettings({ glassTransparencyStrength })
}
/** 更新全局圆角档位。 */
function setRadius(radius: ThemeCustomizerRadius) {
return updateSettings({ radius })
@@ -488,7 +636,13 @@ export function useThemeCustomizer() {
async function resetSettings() {
await updateSettings({
glassAppearance: 'clear',
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassPreset: 'natural',
glassQuality: defaultGlassQuality,
glassReflectionStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
glassTransparencyStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
layout: 'vertical',
primaryColor: defaultPrimaryColor,
radius: 'default',
@@ -525,7 +679,13 @@ export function useThemeCustomizer() {
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
resetSettings,
setGlassAppearance,
setGlassDeformationStrength,
setGlassFlowStrength,
setGlassPreset,
setGlassQuality,
setGlassReflectionStrength,
setGlassTranslationStrength,
setGlassTransparencyStrength,
setLayout,
setPrimaryColor,
setRadius,

View File

@@ -156,6 +156,22 @@ export default {
glassQualityCss: 'Standard',
glassQualityBalanced: 'Balanced',
glassQualityHigh: 'High',
glassQualityCssHint: 'Static CSS material with the lowest resource use; clarity and reflection remain available.',
glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.',
glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.',
glassPreset: 'Preset',
glassPresetNatural: 'Natural',
glassPresetGlide: 'Glide',
glassPresetLiquid: 'Liquid',
glassTranslationStrength: 'Sample Translation',
glassDeformationStrength: 'Deformation',
glassFlowStrength: 'Flow Strength',
glassReflectionStrength: 'Reflection Brightness',
glassTransparencyStrength: 'Clarity',
glassOpticalStrengthHint:
'Sample translation, local deformation, and flow memory stay independent; clarity and reflection do not replace motion.',
glassOpticalStrengthUnavailableHint:
'This environment keeps clarity and static reflection; the three dynamic controls return with desktop live quality.',
purple: 'Purple',
custom: 'Custom Style',
transparency: 'Transparency',

View File

@@ -154,6 +154,20 @@ export default {
glassQualityCss: '标准',
glassQualityBalanced: '均衡',
glassQualityHigh: '高质量',
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透与反射,不启用实时流动。',
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
glassQualityHighHint: '完整时序流场、扩散细节与内容保护GPU 占用更高。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液态',
glassTranslationStrength: '采样平移',
glassDeformationStrength: '形变强度',
glassFlowStrength: '流动强度',
glassReflectionStrength: '反射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '采样平移、局部形变与流动记忆互相独立;通透度和反射不代替动态参数。',
glassOpticalStrengthUnavailableHint: '当前环境保留通透度和静态反射;三个动态参数会在桌面实时质量下恢复。',
purple: '幻紫',
custom: '附加样式',
transparency: '透明度',

View File

@@ -154,6 +154,20 @@ export default {
glassQualityCss: '標準',
glassQualityBalanced: '均衡',
glassQualityHigh: '高品質',
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透與反射,不啟用即時流動。',
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
glassQualityHighHint: '完整時序流場、擴散細節與內容保護GPU 佔用更高。',
glassPreset: '方案',
glassPresetNatural: '自然',
glassPresetGlide: '滑移',
glassPresetLiquid: '液態',
glassTranslationStrength: '採樣平移',
glassDeformationStrength: '形變強度',
glassFlowStrength: '流動強度',
glassReflectionStrength: '反射亮度',
glassTransparencyStrength: '通透度',
glassOpticalStrengthHint: '採樣平移、局部形變與流動記憶互相獨立;通透度和反射不代替動態參數。',
glassOpticalStrengthUnavailableHint: '目前環境保留通透度和靜態反射;三個動態參數會在桌面即時品質下恢復。',
purple: '幻紫',
custom: '附加樣式',
transparency: '透明度',

View File

@@ -3,17 +3,17 @@
// 材质只覆盖统一表面 token质量档只替换光学层不改变业务组件契约。
html[data-theme='glass'] {
--glass-surface: rgba(11, 19, 34, 22%);
--glass-surface-soft: rgba(11, 19, 34, 28%);
--glass-surface-raised: rgba(11, 19, 34, 36%);
--glass-surface: rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24));
--glass-surface-soft: rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24));
--glass-surface-raised: rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24));
--glass-control: rgba(11, 19, 34, 52%);
--glass-control-prominent: rgba(255, 255, 255, 7%);
--glass-control-prominent-focus: color-mix(in srgb, rgba(255, 255, 255, 10%) 84%, rgba(var(--v-theme-primary), 24%));
--glass-border: rgba(255, 255, 255, 10%);
--glass-border-raised: rgba(255, 255, 255, 14%);
--glass-border-hover: rgba(255, 255, 255, 20%);
--glass-border: rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12));
--glass-border-raised: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16));
--glass-border-hover: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2));
--glass-control-prominent-border: rgba(255, 255, 255, 14%);
--glass-highlight: rgba(255, 255, 255, 14%);
--glass-highlight: rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16));
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 9%),
@@ -23,6 +23,11 @@ html[data-theme='glass'] {
);
--glass-shadow:
0 10px 28px rgba(3, 7, 18, 24%), inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
--glass-dashboard-shadow: inset 0 1px 0 var(--glass-highlight);
--glass-dashboard-shadow-hover:
inset 0 1px 0 rgba(255, 255, 255, calc(0.16 + var(--glass-reflection, 0.5) * 0.24)),
inset -1px 0 0 rgba(2, 6, 16, calc(0.12 + var(--glass-reflection, 0.5) * 0.1)),
inset 0 -1px 0 rgba(2, 6, 16, calc(0.14 + var(--glass-reflection, 0.5) * 0.1));
--glass-shadow-raised:
0 16px 40px rgba(3, 7, 18, 32%), inset 0 1px 0 rgba(255, 255, 255, 24%), inset 0 -1px 0 rgba(2, 6, 16, 18%);
--glass-shadow-hover:
@@ -104,9 +109,21 @@ html[data-theme='glass'] {
// 色调保持与透明材质接近的透光度,只叠加主色语义。
&[data-glass-appearance='tinted'] {
--glass-surface: color-mix(in srgb, rgba(11, 19, 34, 24%) 88%, rgba(var(--v-theme-primary), 28%));
--glass-surface-soft: color-mix(in srgb, rgba(11, 19, 34, 30%) 89%, rgba(var(--v-theme-primary), 26%));
--glass-surface-raised: color-mix(in srgb, rgba(11, 19, 34, 38%) 84%, rgba(var(--v-theme-primary), 32%));
--glass-surface: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.3 - var(--glass-transparency, 0.5) * 0.24)) 88%,
rgba(var(--v-theme-primary), 28%)
);
--glass-surface-soft: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.36 - var(--glass-transparency, 0.5) * 0.24)) 89%,
rgba(var(--v-theme-primary), 26%)
);
--glass-surface-raised: color-mix(
in srgb,
rgba(11, 19, 34, calc(0.44 - var(--glass-transparency, 0.5) * 0.24)) 84%,
rgba(var(--v-theme-primary), 32%)
);
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
--glass-control-prominent-focus: color-mix(
@@ -114,15 +131,31 @@ html[data-theme='glass'] {
rgba(255, 255, 255, 10%) 76%,
rgba(var(--v-theme-primary), 32%)
);
--glass-border: color-mix(in srgb, rgba(255, 255, 255, 10%) 82%, rgba(var(--v-theme-primary), 26%));
--glass-border-raised: color-mix(in srgb, rgba(255, 255, 255, 14%) 80%, rgba(var(--v-theme-primary), 30%));
--glass-border-hover: color-mix(in srgb, rgba(255, 255, 255, 20%) 78%, rgba(var(--v-theme-primary), 36%));
--glass-border: color-mix(
in srgb,
rgba(255, 255, 255, calc(0.04 + var(--glass-reflection, 0.5) * 0.12)) 82%,
rgba(var(--v-theme-primary), 26%)
);
--glass-border-raised: color-mix(
in srgb,
rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 80%,
rgba(var(--v-theme-primary), 30%)
);
--glass-border-hover: color-mix(
in srgb,
rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.2)) 78%,
rgba(var(--v-theme-primary), 36%)
);
--glass-control-prominent-border: color-mix(
in srgb,
rgba(255, 255, 255, 14%) 78%,
rgba(var(--v-theme-primary), 32%)
);
--glass-highlight: color-mix(in srgb, rgba(255, 255, 255, 14%) 86%, rgba(var(--v-theme-primary), 18%));
--glass-highlight: color-mix(
in srgb,
rgba(255, 255, 255, calc(0.06 + var(--glass-reflection, 0.5) * 0.16)) 86%,
rgba(var(--v-theme-primary), 18%)
);
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 9%),
@@ -134,17 +167,17 @@ html[data-theme='glass'] {
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
&[data-glass-appearance='frosted'] {
--glass-surface: rgba(255, 255, 255, 8%);
--glass-surface-soft: rgba(255, 255, 255, 7%);
--glass-surface-raised: rgba(255, 255, 255, 12%);
--glass-surface: rgba(255, 255, 255, calc(0.12 - var(--glass-transparency, 0.5) * 0.07));
--glass-surface-soft: rgba(255, 255, 255, calc(0.11 - var(--glass-transparency, 0.5) * 0.06));
--glass-surface-raised: rgba(255, 255, 255, calc(0.16 - var(--glass-transparency, 0.5) * 0.08));
--glass-control: rgba(255, 255, 255, 9%);
--glass-control-prominent: rgba(255, 255, 255, 10%);
--glass-control-prominent-focus: rgba(255, 255, 255, 13%);
--glass-border: rgba(255, 255, 255, 15%);
--glass-border-raised: rgba(255, 255, 255, 18%);
--glass-border-hover: rgba(255, 255, 255, 28%);
--glass-border: rgba(255, 255, 255, calc(0.07 + var(--glass-reflection, 0.5) * 0.16));
--glass-border-raised: rgba(255, 255, 255, calc(0.08 + var(--glass-reflection, 0.5) * 0.2));
--glass-border-hover: rgba(255, 255, 255, calc(0.14 + var(--glass-reflection, 0.5) * 0.28));
--glass-control-prominent-border: rgba(255, 255, 255, 18%);
--glass-highlight: rgba(255, 255, 255, 22%);
--glass-highlight: rgba(255, 255, 255, calc(0.1 + var(--glass-reflection, 0.5) * 0.24));
--glass-sheen: linear-gradient(
135deg,
rgba(255, 255, 255, 7%),
@@ -255,11 +288,8 @@ html[data-theme='glass'] {
background: transparent;
}
// 页面容器不承担玻璃材质,避免内容高度结束处与透明 footer 形成整宽明暗边界。
.layout-page-content {
background-color: rgba(255, 255, 255, 2.5%);
}
&[data-glass-appearance='frosted'] .layout-page-content {
background-color: transparent;
}
@@ -350,6 +380,19 @@ html[data-theme='glass'] {
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
-webkit-backdrop-filter: var(--glass-dashboard-backdrop-filter);
backdrop-filter: var(--glass-dashboard-backdrop-filter);
box-shadow: var(--glass-dashboard-shadow) !important;
}
@media (hover: hover) {
// Dashboard 悬停沿左上来光增强顶部高光,并在右下背光面保留轻吸收。
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card:hover,
.dashboard-grid-item-content
> .dashboard-grid-auto-size
> .dashboard-grid-content-measure
> :first-child
> .v-card:hover {
box-shadow: var(--glass-dashboard-shadow-hover) !important;
}
}
:where(.layout-navbar, .Vue-Toastification__toast) {
@@ -532,8 +575,8 @@ html[data-theme='glass'] {
}
.v-overlay__scrim {
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter);
backdrop-filter: var(--glass-surface-backdrop-filter);
-webkit-backdrop-filter: none;
backdrop-filter: none;
background: rgba(3, 7, 18, 48%);
}
@@ -950,7 +993,6 @@ html[data-theme='glass'] {
// 光学档位只在 renderer ready 后接管背景采样,加载与失败状态继续使用标准 CSS 材质。
.glass-optical-layer {
position: fixed;
z-index: 0;
block-size: 100%;
inline-size: 100%;
@@ -960,9 +1002,13 @@ html[data-theme='glass'] {
transition: opacity 180ms ease-out;
}
// 壁纸交叉淡入期间停用折射输出,避免新纹理采样仍在混合的背景。
.glass-optical-layer--background-transition {
opacity: 0 !important;
.glass-optical-layer--fixed {
position: fixed;
}
// 文档表面与该呈现层共享滚动坐标canvas backing buffer 仍由 renderer 的质量预算约束。
.glass-optical-layer--scroll {
position: absolute;
}
// 登录卡片的壁纸副本与外层背景共享切换时序,避免卡片内部提前显示下一张图片。
@@ -980,6 +1026,8 @@ html[data-theme='glass']:is(
[data-glass-quality='balanced'],
[data-glass-quality='high']
)[data-glass-renderer-state='ready'] {
--glass-surface-backdrop-filter: none;
--glass-raised-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-surface-backdrop-filter);
}
@@ -987,8 +1035,8 @@ html[data-theme='glass'][data-glass-appearance='frosted']:is(
[data-glass-quality='balanced'],
[data-glass-quality='high']
)[data-glass-renderer-state='ready'] {
--glass-navbar-backdrop-filter: blur(18px) saturate(145%);
--glass-overlay-blur: 18px;
--glass-navbar-backdrop-filter: none;
--glass-overlay-blur: 8px;
--glass-overlay-saturate: 145%;
}
@@ -1012,7 +1060,7 @@ html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass
}
}
// 磨砂材质的散射由 renderer 与有限的局部扩散共同构成
// 磨砂材质的散射由 renderer 统一完成,避免 CSS 再次模糊折射结果
html[data-glass-appearance='frosted']:is(
[data-glass-quality='balanced'],
[data-glass-quality='high']
@@ -1020,14 +1068,14 @@ html[data-glass-appearance='frosted']:is(
body[data-theme='glass'] {
.layout-vertical-nav::before,
.layout-navbar {
-webkit-backdrop-filter: blur(18px) saturate(145%) !important;
backdrop-filter: blur(18px) saturate(145%) !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card {
-webkit-backdrop-filter: blur(24px) saturate(150%) !important;
backdrop-filter: blur(24px) saturate(150%) !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
}

View File

@@ -1,4 +1,4 @@
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
import { describe, expect, it, vi } from 'vitest'
function deferred<T>() {
@@ -59,3 +59,31 @@ describe('commitPreloadedBackgroundRotation', () => {
expect(commit).not.toHaveBeenCalled()
})
})
describe('preloadBackgroundRotationImages', () => {
it('does not let an unused optical texture block the visible wallpaper', async () => {
const preload = vi.fn(async (url: string) => url === 'display.jpg')
await expect(
preloadBackgroundRotationImages({
displayUrl: 'display.jpg',
preload,
}),
).resolves.toBe(true)
expect(preload).toHaveBeenCalledOnce()
expect(preload).toHaveBeenCalledWith('display.jpg')
})
it('requires both textures when the optical renderer consumes the derived wallpaper', async () => {
const preload = vi.fn(async (url: string) => url === 'display.jpg')
await expect(
preloadBackgroundRotationImages({
displayUrl: 'display.jpg',
opticalUrl: 'optical.jpg',
preload,
}),
).resolves.toBe(false)
expect(preload).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,20 +1,41 @@
import {
canUseGlassWallpaperTexture,
GLASS_OPTICAL_MOTION_MAX_SCALE,
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCoverScale,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
getGlassOpticalMaxRefractionPixels,
getGlassOpticalMotionEnergy,
getGlassOpticalMotionExpansion,
getGlassOpticalMotionStrengthScale,
getGlassOpticalPresetParameters,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
getGlassOpticalSurfaceTransitionWeights,
getGlassOpticalWakeDirection,
getGlassOpticalWakeSample,
getGlassScrollBufferSize,
getGlassWallpaperTransitionProgress,
normalizeGlassOpticalRect,
normalizeGlassOpticalStrength,
reconcileGlassOpticalSurfaceSlots,
selectGlassOpticalRects,
stepGlassOpticalSpring,
type GlassOpticalRect,
} from '@/utils/glassOptics'
import { describe, expect, it } from 'vitest'
describe('glass optics geometry', () => {
it('caps the renderer buffer independently from device pixel ratio', () => {
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 })
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 })
expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 })
expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 })
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 931, width: 1440 })
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 844, width: 390 })
expect(getGlassOpticalBufferSize(1920, 1080, false, 'high', 2)).toEqual({ height: 1080, width: 1920 })
expect(getGlassOpticalBufferSize(390, 844, true, 'high', 3)).toEqual({ height: 1266, width: 585 })
expect(getGlassScrollBufferSize(1440, 4200, 'balanced', 2)).toEqual({ height: 3072, width: 1440 })
expect(getGlassScrollBufferSize(1440, 4200, 'high', 2)).toEqual({ height: 4096, width: 1920 })
})
it('matches cover cropping on wide and tall images', () => {
@@ -22,34 +43,305 @@ describe('glass optics geometry', () => {
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
})
it('maps user strength sliders to accelerated high-range optical response', () => {
expect(normalizeGlassOpticalStrength(Number.NaN)).toBe(50)
expect(normalizeGlassOpticalStrength(-12)).toBe(0)
expect(normalizeGlassOpticalStrength(44.6)).toBe(45)
expect(normalizeGlassOpticalStrength(160)).toBe(100)
expect(getGlassOpticalMotionStrengthScale(0)).toBe(0)
expect(getGlassOpticalMotionStrengthScale(50)).toBe(1)
expect(getGlassOpticalMotionStrengthScale(80)).toBeGreaterThan(2)
expect(getGlassOpticalMotionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_MOTION_MAX_SCALE)
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
)
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
expect(getGlassOpticalMaxRefractionPixels(9, 80)).toBe(9)
expect(getGlassOpticalMaxRefractionPixels(9, 100)).toBe(9)
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86)
expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96)
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan(
getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50),
)
})
it('keeps presets as concrete five-parameter values', () => {
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
expect(natural).toEqual({
deformation: 50,
flow: 50,
reflection: 50,
translation: 50,
transparency: 50,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
expect(liquid.flow).toBeGreaterThan(glide.flow)
expect(getAvailableGlassOpticalPresets('css')).toEqual(['natural'])
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
})
it('returns preset copies so previews cannot mutate the shared matrix', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
first.translation = 0
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
})
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
const samples = [0, 250, 750, 1250, 1500].map(elapsed => getGlassWallpaperTransitionProgress(elapsed, 1500))
expect(samples[0]).toBe(0)
expect(samples.at(-1)).toBe(1)
expect(samples[2]).toBeGreaterThan(0.5)
expect(samples.every((sample, index) => index === 0 || sample > samples[index - 1])).toBe(true)
})
it('keeps the selected optical quality on every route', () => {
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'wallpaper',
trailCount: 4,
})
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
bufferQuality: 'balanced',
textureLimit: 2048,
contentProtection: false,
diffusionSamples: 5,
flowField: false,
flowHalfLife: 0,
maxRefractionPixels: 6,
motionDuration: 360,
motionHalfLife: 82,
pixelRatioCap: 1,
pointerImmediateResponse: 0.7,
springDamping: 0.9,
springFrequency: 24,
textureLimit: 3072,
textureSource: 'wallpaper',
trailCount: 2,
})
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
bufferQuality: 'high',
textureLimit: 3072,
contentProtection: true,
diffusionSamples: 9,
flowField: true,
flowHalfLife: 130,
maxRefractionPixels: 9,
motionDuration: 540,
motionHalfLife: 125,
pixelRatioCap: 1.5,
pointerImmediateResponse: 0.58,
springDamping: 0.78,
springFrequency: 18,
textureLimit: 4096,
textureSource: 'auto',
trailCount: 4,
})
})
it('spends high-quality cost on visible detail and content protection', () => {
const balanced = getGlassOpticalRenderProfile('balanced', '/dashboard')
const high = getGlassOpticalRenderProfile('high', '/dashboard')
expect(balanced).toMatchObject({
contentProtection: false,
diffusionSamples: 5,
flowField: false,
maxRefractionPixels: 6,
trailCount: 2,
})
expect(high).toMatchObject({
contentProtection: true,
diffusionSamples: 9,
flowField: true,
maxRefractionPixels: 9,
trailCount: 4,
})
expect(high.motionDuration).toBeGreaterThan(balanced.motionDuration)
expect(high.pixelRatioCap).toBeGreaterThan(balanced.pixelRatioCap)
})
it('uses refresh-rate independent decay and reaches a deterministic static state', () => {
expect(getGlassOpticalDecay(100, 100)).toBeCloseTo(0.5)
expect(getGlassOpticalDecay(100, 50) ** 2).toBeCloseTo(getGlassOpticalDecay(100, 100))
expect(getGlassOpticalMotionEnergy(0, 420, 90)).toBe(1)
expect(getGlassOpticalMotionEnergy(90, 420, 90)).toBeCloseTo(0.5)
expect(getGlassOpticalMotionEnergy(400, 420, 90)).toBeLessThan(0.005)
expect(getGlassOpticalMotionEnergy(410, 420, 90)).toBeLessThan(getGlassOpticalMotionEnergy(400, 420, 90))
expect(getGlassOpticalMotionEnergy(420, 420, 90)).toBe(0)
const samples = Array.from({ length: 29 }, (_, index) => getGlassOpticalMotionEnergy(index * 15, 420, 90))
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
})
it('keeps one crest and one trough in the event-relative liquid wake', () => {
const samples = Array.from({ length: 161 }, (_, index) => getGlassOpticalWakeSample(-4 + index * 0.05))
const extrema = samples
.slice(1, -1)
.filter(
(sample, index) =>
(sample > samples[index] && sample > samples[index + 2]) ||
(sample < samples[index] && sample < samples[index + 2]),
)
expect(extrema).toHaveLength(2)
expect(Math.min(...samples)).toBeLessThan(0)
expect(Math.max(...samples)).toBeGreaterThan(0)
expect(getGlassOpticalWakeSample(0)).toBe(0)
})
it('locks wake direction until a deliberate turn starts a new impulse', () => {
expect(
getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: Math.cos(Math.PI / 8), y: Math.sin(Math.PI / 8) }, 0.04, false),
).toEqual({ x: 1, y: 0 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.04, false)).toEqual({ x: 0, y: 1 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.002, false)).toEqual({ x: 1, y: 0 })
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 2 }, 0.04, true)).toEqual({ x: 0, y: 1 })
})
it('uses a frame-rate independent spring with at most one visible overshoot', () => {
const profile = getGlassOpticalRenderProfile('high', '/dashboard')
const simulate = (deltaMs: number) => {
let state = { position: 0, velocity: 0 }
const samples: number[] = []
for (let elapsed = 0; elapsed < profile.motionDuration; elapsed += deltaMs) {
state = stepGlassOpticalSpring(state, 1, deltaMs, profile.springFrequency, profile.springDamping)
samples.push(state.position)
}
return { samples, state }
}
const sixtyHertz = simulate(1000 / 60)
const oneTwentyHertz = simulate(1000 / 120)
const visibleCrossings = sixtyHertz.samples.slice(1).filter((sample, index) => {
const previousOffset = sixtyHertz.samples[index] - 1
const currentOffset = sample - 1
return previousOffset * currentOffset < 0 && Math.max(Math.abs(previousOffset), Math.abs(currentOffset)) > 0.002
})
expect(visibleCrossings.length).toBeLessThanOrEqual(1)
expect(Math.max(...sixtyHertz.samples)).toBeLessThan(1.04)
expect(sixtyHertz.state.position).toBeCloseTo(1, 2)
expect(sixtyHertz.state.position).toBeCloseTo(oneTwentyHertz.state.position, 3)
})
it('reconciles capped surface slots without reshuffling stable cards', () => {
const candidates = Array.from({ length: 10 }, (_, index) => ({
key: `card-${index}`,
rect: {
height: 80,
radii: [12, 12, 12, 12] as [number, number, number, number],
rank: index + 1,
width: 90,
x: index * 100,
y: 20,
},
}))
const initial = reconcileGlassOpticalSurfaceSlots([], candidates, 8)
const onCardA = reconcileGlassOpticalSurfaceSlots(initial, candidates, 8, 'card-8')
const throughGap = reconcileGlassOpticalSurfaceSlots(onCardA, candidates, 8, 'card-8')
const onCardB = reconcileGlassOpticalSurfaceSlots(throughGap, candidates, 8, 'card-9', 'card-8')
expect(initial.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-6',
'card-7',
])
expect(onCardA.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-6',
'card-8',
])
expect(throughGap.map(slot => slot.key)).toEqual(onCardA.map(slot => slot.key))
expect(onCardB.map(slot => slot.key)).toEqual([
'card-0',
'card-1',
'card-2',
'card-3',
'card-4',
'card-5',
'card-8',
'card-9',
])
expect(onCardB.at(-2)?.role).toBe('outgoing')
expect(onCardB.at(-1)?.role).toBe('active')
})
it('crossfades active surface weights monotonically', () => {
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
})
it('only uploads browser-readable login wallpapers to WebGL', () => {
const documentUrl = 'https://moviepilot.example/login'
@@ -63,10 +355,10 @@ describe('glass optics geometry', () => {
it('keeps high-value outer surfaces and removes nested repeats', () => {
const candidates: GlassOpticalRect[] = [
{ height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 },
{ height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 },
{ height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 },
{ height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 },
{ height: 900, radii: [0, 0, 0, 0], rank: 2, width: 260, x: 0, y: 0 },
{ height: 300, radii: [16, 16, 16, 16], rank: 4, width: 500, x: 300, y: 100 },
{ height: 100, radii: [12, 12, 12, 12], rank: 5, width: 200, x: 320, y: 120 },
{ height: 120, radii: [12, 12, 12, 12], rank: 1, width: 400, x: 500, y: 700 },
]
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
@@ -77,20 +369,20 @@ describe('glass optics geometry', () => {
it('keeps original geometry while using the viewport intersection for visibility', () => {
const selected = selectGlassOpticalRects(
[{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }],
[{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }],
320,
240,
false,
)
expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }])
expect(selected).toEqual([{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }])
})
it('budgets partially visible surfaces by their visible pixels', () => {
const selected = selectGlassOpticalRects(
[
{ height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 },
{ height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 },
{ height: 1000, radii: [20, 20, 20, 20], rank: 1, width: 1000, x: -950, y: -900 },
{ height: 120, radii: [16, 16, 16, 16], rank: 2, width: 200, x: 80, y: 80 },
],
320,
240,
@@ -102,11 +394,58 @@ describe('glass optics geometry', () => {
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
expect(
normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500),
).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] })
normalizeGlassOpticalRect(
{ height: 100, radii: [20, 18, 16, 14], rank: 1, width: 200, x: 100, y: 50 },
1000,
500,
),
).toEqual({ radii: [20, 18, 16, 14], rect: [0.1, 0.7, 0.2, 0.2] })
expect(
normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500),
).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] })
normalizeGlassOpticalRect(
{ height: 100, radii: [80, 70, 60, 40], rank: 1, width: 200, x: -20, y: 50 },
1000,
500,
),
).toEqual({
radii: [80 * (10 / 13), 70 * (10 / 13), 60 * (10 / 13), 40 * (10 / 13)],
rect: [-0.02, 0.7, 0.2, 0.2],
})
expect(
normalizeGlassOpticalRect({ height: 100, radii: [80, 30, 0, 0], rank: 1, width: 100, x: 0, y: 0 }, 100, 100)
.radii,
).toEqual([80 * (10 / 11), 30 * (10 / 11), 0, 0])
})
it('keeps visible surfaces without an area-based hard cutoff', () => {
const selected = selectGlassOpticalRects(
[
{ height: 400, radii: [20, 20, 20, 20], rank: 1, width: 700, x: 0, y: 0 },
{ height: 400, radii: [20, 20, 20, 20], rank: 2, width: 700, x: 0, y: 400 },
],
700,
800,
false,
)
expect(selected).toHaveLength(2)
})
it('prioritizes the surface under the current interaction when the count is capped', () => {
const candidates = Array.from({ length: 9 }, (_, index): GlassOpticalRect => ({
height: 80,
radii: [12, 12, 12, 12],
rank: index + 1,
width: 100,
x: index * 110,
y: 20,
}))
const selected = selectGlassOpticalRects(candidates, 1000, 200, false, { x: 930, y: 60 })
expect(selected).toHaveLength(8)
expect(selected).toContain(candidates[8])
expect(selected).not.toContain(candidates[7])
})
})

View File

@@ -7,6 +7,15 @@ interface PreloadedBackgroundRotationOptions {
preload: () => Promise<boolean>
}
interface BackgroundRotationImagePreloadOptions {
/** 外层背景实际显示的壁纸地址。 */
displayUrl: string
/** 实时光学 renderer 确实会消费时才提供的同源纹理地址。 */
opticalUrl?: string
/** 执行单张图片预加载并返回可用状态。 */
preload: (url: string) => Promise<boolean>
}
/**
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
*/
@@ -18,3 +27,16 @@ export async function commitPreloadedBackgroundRotation(options: PreloadedBackgr
options.commit()
return true
}
/**
* 预加载一次轮换真正依赖的壁纸;未启用光学采样时不让派生纹理阻断外层背景切换。
*/
export async function preloadBackgroundRotationImages(options: BackgroundRotationImagePreloadOptions) {
const urls =
options.opticalUrl && options.opticalUrl !== options.displayUrl
? [options.displayUrl, options.opticalUrl]
: [options.displayUrl]
const results = await Promise.all(urls.map(url => options.preload(url)))
return results.every(Boolean)
}

View File

@@ -1,13 +1,45 @@
export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8
export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5
export const GLASS_OPTICAL_MOTION_MAX_SCALE = 3.2
export const GLASS_OPTICAL_DEFORMATION_MAX_SCALE = 1.55
export const GLASS_OPTICAL_FLOW_MAX_SCALE = 1.45
export const GLASS_OPTICAL_REFLECTION_MAX_SCALE = 1.8
export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7
export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50
export const GLASS_OPTICAL_STRENGTH_MAX = 100
export const GLASS_OPTICAL_STRENGTH_MIN = 0
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
export type GlassOpticalQuality = 'balanced' | 'high'
export type GlassCornerRadii = [number, number, number, number]
export interface GlassOpticalParameters {
/** 局部非均匀折射与内容弯曲强度。 */
deformation: number
/** 轨迹范围、尾波、惯性与收敛强度。 */
flow: number
/** 方向高光、迎光棱镜与背光吸收强度。 */
reflection: number
/** 共享壁纸采样在表面内的统一坐标平移强度。 */
translation: number
/** 壁纸可见度与材质遮罩强度。 */
transparency: number
}
export interface GlassInteractionPoint {
/** 指针或触点相对视口的横坐标。 */
x: number
/** 指针或触点相对视口的纵坐标。 */
y: number
}
export interface GlassOpticalRect {
/** 元素的实际高度,元素可部分位于视口外。 */
height: number
/** 元素圆角的 CSS 像素值。 */
radius: number
/** 按左上、右上、右下、左下排列的 CSS 圆角像素值。 */
radii: GlassCornerRadii
/** 表面的场景优先级,数值越小越优先。 */
rank: number
/** 元素的实际宽度,元素可部分位于视口外。 */
@@ -23,13 +55,240 @@ export interface GlassOpticalBufferSize {
width: number
}
export interface GlassOpticalPoint {
/** 归一化或视口坐标系中的横向分量。 */
x: number
/** 归一化或视口坐标系中的纵向分量。 */
y: number
}
export interface GlassOpticalSpringState {
/** 当前跟随位置。 */
position: number
/** 当前每秒位移速度。 */
velocity: number
}
export interface GlassOpticalSurfaceCandidate<TKey> {
/** renderer 生命周期内稳定的表面身份。 */
key: TKey
/** 表面的当前视口几何。 */
rect: GlassOpticalRect
}
export interface GlassOpticalSurfaceSlot<TKey> extends GlassOpticalSurfaceCandidate<TKey> {
/** 槽位在本次交互中的职责。 */
role: 'active' | 'outgoing' | 'stable'
}
export interface GlassOpticalRenderProfile {
/** 光学层内部缓冲使用的质量档位。 */
bufferQuality: GlassOpticalQuality
/** 是否使用额外壁纸采样保护人物和文字等高梯度内容。 */
contentProtection: boolean
/** 磨砂扩散使用的纹理采样数。 */
diffusionSamples: 5 | 9
/** 是否使用带时序记忆的液态位移场。 */
flowField: boolean
/** 时序位移场能量衰减到一半所需的时间。 */
flowHalfLife: number
/** 动态折射在视口像素空间中的软上限。 */
maxRefractionPixels: number
/** 输入停止后液态形态收敛到静态所需的时间。 */
motionDuration: number
/** 主液态反馈能量衰减到一半所需的时间。 */
motionHalfLife: number
/** 高质量缓冲允许使用的设备像素比上限。 */
pixelRatioCap: number
/** 新输入立即作用于镜片位置的比例。 */
pointerImmediateResponse: number
/** 镜片跟随弹簧的阻尼比。 */
springDamping: number
/** 镜片跟随弹簧的角频率,单位弧度每秒。 */
springFrequency: number
/** 活动壁纸进入 GPU 前的最长边限制。 */
textureLimit: number
/** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */
textureSource: 'auto' | 'procedural' | 'wallpaper'
/** 参与液态方向计算的最近输入采样数量。 */
trailCount: number
}
/** 将用户滑杆输入收敛到 renderer 支持的整数范围,非法存量值回落到默认视觉。 */
export function normalizeGlassOpticalStrength(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) return GLASS_OPTICAL_STRENGTH_DEFAULT
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<
GlassAppearance,
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
> = {
clear: {
css: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 },
},
balanced: {
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 },
liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 },
},
high: {
natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 },
glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 },
liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 },
},
},
tinted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 },
},
balanced: {
natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 },
},
high: {
natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 },
},
},
frosted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 },
glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 },
},
balanced: {
natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 },
},
high: {
natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 },
},
},
}
/** 返回材质、质量与预置共同确定的五个具体参数,调用方可以安全修改返回值。 */
export function getGlassOpticalPresetParameters(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
): GlassOpticalParameters {
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
}
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability): GlassOpticalPreset[] {
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
}
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
if (duration <= 0 || elapsed >= duration) return 1
if (elapsed <= 0) return 0
const target = elapsed / duration
const sample = (time: number, start: number, end: number) => {
const inverse = 1 - time
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
}
let lower = 0
let upper = 1
let parameter = target
for (let iteration = 0; iteration < 10; iteration += 1) {
parameter = (lower + upper) * 0.5
if (sample(parameter, 0.25, 0.25) < target) lower = parameter
else upper = parameter
}
return sample(parameter, 0.1, 1)
}
/**
* 流动强度使用感知曲线:中点保持既有视觉,高区间同时释放更大的形变幅度与空间范围。
* 最大值仍受质量档和内容保护共同约束,避免高梯度海报出现无界拉伸。
*/
export function getGlassOpticalMotionStrengthScale(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
return normalizedRatio ** Math.log2(GLASS_OPTICAL_MOTION_MAX_SCALE)
}
/** 采样平移保持中点等于既有即时位移,并在高区间受控增长。 */
export function getGlassOpticalTranslationStrengthScale(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
return normalizedRatio ** Math.log2(GLASS_OPTICAL_TRANSLATION_MAX_SCALE)
}
/** 非均匀形变独立缩放局部折射,最终像素位移仍受质量档软上限限制。 */
export function getGlassOpticalDeformationStrengthScale(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
return normalizedRatio ** Math.log2(GLASS_OPTICAL_DEFORMATION_MAX_SCALE)
}
/** 流动维度只延展轨迹、尾波与惯性,中点保持当前时序手感。 */
export function getGlassOpticalFlowStrengthScale(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
}
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
export function getGlassOpticalMotionExpansion(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const highRangeProgress = Math.max(
0,
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
)
return highRangeProgress ** 1.55
}
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
export function getGlassOpticalMaxRefractionPixels(basePixels: number, _value: unknown) {
void _value
return basePixels
}
/** 反射强度使用独立感知曲线,只控制光学亮度,不放大背景位移。 */
export function getGlassOpticalReflectionStrengthScale(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
}
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */
export function getGlassOpticalTransparency(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
if (normalized <= 80) {
const progress = normalized / 80
return 0.28 + 0.58 * progress ** 1.25
}
const highRangeProgress = (normalized - 80) / 20
return 0.86 + 0.1 * highRangeProgress ** 1.5
}
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
@@ -37,13 +296,174 @@ export function getGlassOpticalRenderProfile(
quality: GlassOpticalQuality,
routeKey: string,
): GlassOpticalRenderProfile {
const highQuality = quality === 'high'
return {
bufferQuality: quality,
textureLimit: quality === 'high' ? 3072 : 2048,
contentProtection: highQuality,
diffusionSamples: highQuality ? 9 : 5,
flowField: highQuality,
flowHalfLife: highQuality ? 130 : 0,
maxRefractionPixels: highQuality ? 9 : 6,
motionDuration: highQuality ? 540 : 360,
motionHalfLife: highQuality ? 125 : 82,
pixelRatioCap: highQuality ? 1.5 : 1,
pointerImmediateResponse: highQuality ? 0.58 : 0.7,
springDamping: highQuality ? 0.78 : 0.9,
springFrequency: highQuality ? 18 : 24,
textureLimit: highQuality ? 4096 : 3072,
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
trailCount: highQuality ? 4 : 2,
}
}
/** 将时间常数转换为与刷新率无关的指数衰减。 */
export function getGlassOpticalDecay(halfLife: number, delta: number) {
if (halfLife <= 0) return 0
if (delta <= 0) return 1
return 2 ** (-delta / halfLife)
}
/** 输入停止后按时间收敛,尾段平滑归零以恢复事件驱动静止状态。 */
export function getGlassOpticalMotionEnergy(elapsed: number, duration: number, halfLife: number) {
if (elapsed >= duration) return 0
const safeElapsed = Math.max(0, elapsed)
const tailDuration = Math.max(1, duration * 0.25)
const tailProgress = Math.min(1, Math.max(0, (duration - safeElapsed) / tailDuration))
const tailTaper = tailProgress * tailProgress * (3 - 2 * tailProgress)
return getGlassOpticalDecay(halfLife, safeElapsed) * tailTaper
}
/**
* 解析阻尼弹簧的精确时间步,避免刷新率变化改变跟随手感。
* 欠阻尼参数只允许一次可见回摆,生命周期结束后由 renderer 归零。
*/
export function stepGlassOpticalSpring(
state: GlassOpticalSpringState,
target: number,
deltaMs: number,
frequency: number,
damping: number,
): GlassOpticalSpringState {
const deltaSeconds = Math.min(0.064, Math.max(0, deltaMs / 1000))
const safeFrequency = Math.max(0.001, frequency)
const safeDamping = Math.max(0, damping)
const offset = state.position - target
if (deltaSeconds <= 0) return state
if (safeDamping >= 1) {
const decay = Math.exp(-safeFrequency * deltaSeconds)
const coefficient = state.velocity + safeFrequency * offset
return {
position: target + (offset + coefficient * deltaSeconds) * decay,
velocity: (state.velocity - safeFrequency * coefficient * deltaSeconds) * decay,
}
}
const dampedFrequency = safeFrequency * Math.sqrt(1 - safeDamping * safeDamping)
const decay = Math.exp(-safeDamping * safeFrequency * deltaSeconds)
const angle = dampedFrequency * deltaSeconds
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
const sineCoefficient = (state.velocity + safeDamping * safeFrequency * offset) / dampedFrequency
const oscillation = offset * cosine + sineCoefficient * sine
return {
position: target + decay * oscillation,
velocity:
decay *
(-safeDamping * safeFrequency * oscillation -
offset * dampedFrequency * sine +
sineCoefficient * dampedFrequency * cosine),
}
}
/** 单个双相尾波只有一个波峰和一个波谷,不形成连续周期波列。 */
export function getGlassOpticalWakeSample(position: number) {
return position * Math.exp(-0.5 * position * position)
}
/** 低速噪声和小角度移动沿用既有方向,显著转向才开始新的尾波。 */
export function getGlassOpticalWakeDirection(
current: GlassOpticalPoint,
next: GlassOpticalPoint,
speed: number,
restart: boolean,
): GlassOpticalPoint {
const normalize = (point: GlassOpticalPoint) => {
const length = Math.hypot(point.x, point.y)
return length > 0.0001 ? { x: point.x / length, y: point.y / length } : { x: 0, y: -1 }
}
const currentDirection = normalize(current)
const nextDirection = normalize(next)
if (restart || Math.hypot(current.x, current.y) <= 0.0001) return nextDirection
if (speed < 0.006) return currentDirection
const directionDot = currentDirection.x * nextDirection.x + currentDirection.y * nextDirection.y
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
}
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
const eased = progress * progress * (3 - 2 * progress)
return {
incoming: 0.35 + eased * 0.65,
outgoing: 1 - eased,
}
}
/**
* 复用仍可见的稳定槽位,并为当前与离场表面保留确定位置。
* 候选顺序只用于补充空位,指针移动不会重排已经占用的稳定槽位。
*/
export function reconcileGlassOpticalSurfaceSlots<TKey>(
previous: GlassOpticalSurfaceSlot<TKey>[],
candidates: GlassOpticalSurfaceCandidate<TKey>[],
maxCount: number,
activeKey?: TKey,
outgoingKey?: TKey,
): GlassOpticalSurfaceSlot<TKey>[] {
if (maxCount <= 0) return []
const candidateByKey = new Map(candidates.map(candidate => [candidate.key, candidate]))
const reserved: GlassOpticalSurfaceSlot<TKey>[] = []
if (outgoingKey !== undefined && outgoingKey !== activeKey) {
const outgoing = candidateByKey.get(outgoingKey)
if (outgoing) reserved.push({ ...outgoing, role: 'outgoing' })
}
if (activeKey !== undefined) {
const active = candidateByKey.get(activeKey)
if (active) reserved.push({ ...active, role: 'active' })
}
const reservedKeys = new Set(reserved.map(slot => slot.key))
const stableCount = Math.max(0, maxCount - reserved.length)
const stable: GlassOpticalSurfaceSlot<TKey>[] = []
const stableKeys = new Set<TKey>()
const appendStable = (key: TKey) => {
if (stable.length >= stableCount || reservedKeys.has(key) || stableKeys.has(key)) return
const candidate = candidateByKey.get(key)
if (!candidate) return
stable.push({ ...candidate, role: 'stable' })
stableKeys.add(key)
}
for (const slot of previous) appendStable(slot.key)
for (const candidate of candidates) appendStable(candidate.key)
return [...stable, ...reserved].slice(0, maxCount)
}
/** 只将同源及本地对象交给 WebGL避免跨域纹理失败污染登录页控制台。 */
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
if (!url || !documentUrl) return false
@@ -58,18 +478,20 @@ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): b
}
}
/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */
/** 按质量档位的像素预算计算内部缓冲,高质量只使用受控的设备像素比增益。 */
export function getGlassOpticalBufferSize(
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
quality: GlassOpticalQuality = 'balanced',
devicePixelRatio = 1,
): GlassOpticalBufferSize {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
const highQuality = quality === 'high'
const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960
const maxHeight = highQuality ? 960 : 720
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
const safeWidth = Math.max(1, viewportWidth) * pixelRatio
const safeHeight = Math.max(1, viewportHeight) * pixelRatio
const maxWidth = mobile ? (highQuality ? 1280 : 960) : highQuality ? 1920 : 1440
const maxHeight = mobile ? (highQuality ? 1440 : 960) : highQuality ? 1200 : 960
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight)
return {
@@ -78,6 +500,24 @@ export function getGlassOpticalBufferSize(
}
}
/** 文档空间画布独立限制横纵分辨率,避免长页面按纵横比连带降低横向清晰度。 */
export function getGlassScrollBufferSize(
presentationWidth: number,
presentationHeight: number,
quality: GlassOpticalQuality,
devicePixelRatio = 1,
): GlassOpticalBufferSize {
const highQuality = quality === 'high'
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
const maxWidth = highQuality ? 1920 : 1440
const maxHeight = highQuality ? 4096 : 3072
return {
height: Math.max(1, Math.round(Math.min(presentationHeight * pixelRatio, maxHeight))),
width: Math.max(1, Math.round(Math.min(presentationWidth * pixelRatio, maxWidth))),
}
}
/** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */
export function getGlassCoverScale(
viewportWidth: number,
@@ -99,10 +539,9 @@ export function selectGlassOpticalRects(
viewportWidth: number,
viewportHeight: number,
mobile: boolean,
interactionPoint?: GlassInteractionPoint,
): GlassOpticalRect[] {
const viewportArea = Math.max(1, viewportWidth * viewportHeight)
const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
const maxArea = viewportArea * (mobile ? 0.68 : 0.82)
const visible = candidates
.map(rect => {
const left = Math.max(0, rect.x)
@@ -120,10 +559,26 @@ export function selectGlassOpticalRects(
}
})
.filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24)
.sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea)
.sort((left, right) => {
if (interactionPoint) {
const leftContainsInteraction =
interactionPoint.x >= left.rect.x &&
interactionPoint.x <= left.rect.x + left.rect.width &&
interactionPoint.y >= left.rect.y &&
interactionPoint.y <= left.rect.y + left.rect.height
const rightContainsInteraction =
interactionPoint.x >= right.rect.x &&
interactionPoint.x <= right.rect.x + right.rect.width &&
interactionPoint.y >= right.rect.y &&
interactionPoint.y <= right.rect.y + right.rect.height
if (leftContainsInteraction !== rightContainsInteraction) return leftContainsInteraction ? -1 : 1
}
return left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea
})
const selected: typeof visible = []
let selectedArea = 0
for (const candidate of visible) {
if (selected.length >= maxCount) break
@@ -138,10 +593,7 @@ export function selectGlassOpticalRects(
)
if (nested) continue
if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue
selected.push(candidate)
selectedArea += candidate.visibleArea
}
return selected.map(candidate => candidate.rect)
@@ -151,9 +603,20 @@ export function selectGlassOpticalRects(
export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) {
const safeWidth = Math.max(1, viewportWidth)
const safeHeight = Math.max(1, viewportHeight)
const radii = rect.radii.map(radius => Math.max(0, radius)) as GlassCornerRadii
const [topLeft, topRight, bottomRight, bottomLeft] = radii
const fitScale = (side: number, radiusSum: number) => (radiusSum > 0 ? Math.max(0, side) / radiusSum : 1)
// CSS 会用同一个比例缩小全部圆角,保证任一边的相邻半径之和不超过该边长度。
const radiusScale = Math.min(
1,
fitScale(rect.width, topLeft + topRight),
fitScale(rect.width, bottomLeft + bottomRight),
fitScale(rect.height, topLeft + bottomLeft),
fitScale(rect.height, topRight + bottomRight),
)
return {
radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)),
radii: radii.map(radius => radius * radiusScale) as GlassCornerRadii,
rect: [
rect.x / safeWidth,
1 - (rect.y + rect.height) / safeHeight,