From 98bd33e3fc826b63eeff76d0027062be4b6d212d Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 6 Sep 2026 20:00:25 +0800 Subject: [PATCH 1/5] fix(glass): align navigation spacing radii and plugin branding --- .../theme/GlassFixedShellBackplate.vue | 1 + .../theme/GlassNavbarRefractionDefs.vue | 9 +++- .../GlassFixedShellBackplate.spec.ts | 16 +++++++ .../GlassNavbarRefractionDefs.spec.ts | 16 +++++++ .../__tests__/glassOverlayMaterial.spec.ts | 20 ++++++++ src/styles/themes/_glass-v3.scss | 48 ++++++++++++------- .../__tests__/glassNavbarRefraction.spec.ts | 4 ++ 7 files changed, 96 insertions(+), 18 deletions(-) diff --git a/src/components/theme/GlassFixedShellBackplate.vue b/src/components/theme/GlassFixedShellBackplate.vue index 488fe53b..04146a75 100644 --- a/src/components/theme/GlassFixedShellBackplate.vue +++ b/src/components/theme/GlassFixedShellBackplate.vue @@ -53,6 +53,7 @@ const GEOMETRY_ATTRIBUTE_FILTER = [ 'data-shell-mode', 'data-shell-navbar-attachment', 'data-theme', + 'data-theme-radius', 'style', ] diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index 2306984e..d8412e95 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -350,7 +350,14 @@ onMounted(() => { stateObserver.observe(document.documentElement, { attributes: true, attributeOldValue: true, - attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'], + attributeFilter: [ + 'class', + 'style', + 'data-theme', + 'data-theme-radius', + 'data-glass-appearance', + 'data-glass-quality', + ], }) stateObserver.observe(observedShell, { attributes: true, diff --git a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts index baa0e606..ddb659ed 100644 --- a/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts +++ b/src/components/theme/__tests__/GlassFixedShellBackplate.spec.ts @@ -161,6 +161,7 @@ describe('GlassFixedShellBackplate', () => { for (const wrapper of mountedWrappers.splice(0)) wrapper.unmount() document.querySelectorAll('.layout-wrapper').forEach(element => element.remove()) document.documentElement.removeAttribute('data-theme') + document.documentElement.removeAttribute('data-theme-radius') vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -265,6 +266,21 @@ describe('GlassFixedShellBackplate', () => { expect(wrapper.findAll('clipPath rect')).toHaveLength(0) }) + it('updates the shared clip when theme radius changes without resizing navigation', async () => { + const { wrapper, sidebar } = mountConnectedShell() + await settleGeometry() + const style = window.getComputedStyle(sidebar) + vi.mocked(window.getComputedStyle).mockReturnValue({ ...style, borderTopLeftRadius: '24px' }) + + document.documentElement.dataset.themeRadius = 'extra' + await settleGeometry() + + for (const rect of wrapper.findAll('clipPath rect')) { + expect(Number(rect.attributes('rx'))).toBeCloseTo(24 / backplateRect.width, 8) + expect(Number(rect.attributes('ry'))).toBeCloseTo(24 / backplateRect.height, 8) + } + }) + it('refreshes dimensions and disconnects the resize observer on unmount', async () => { const { wrapper } = mountConnectedShell() await settleGeometry() diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index 91f3117b..b4d41024 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -107,6 +107,7 @@ describe('GlassNavbarRefractionDefs', () => { wrapper?.unmount() wrapper = undefined shell.remove() + delete document.documentElement.dataset.themeRadius vi.restoreAllMocks() vi.unstubAllGlobals() vi.useRealTimers() @@ -420,6 +421,21 @@ describe('GlassNavbarRefractionDefs', () => { ) }) + it('regenerates the optical outline when the theme radius attribute changes', async () => { + wrapper = mount(GlassNavbarRefractionDefs) + await settle() + + radius = 24 + document.documentElement.dataset.themeRadius = 'extra' + await flushPromises() + await settle() + + expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( + expect.objectContaining({ width: 1423, height: 64, radius: 24 }), + ) + expect(shell.dataset.glassNavbarRefractionReady).toBe('true') + }) + it('does not retry a failed geometry in a feedback loop', async () => { wrapper = mount(GlassNavbarRefractionDefs) await vi.advanceTimersByTimeAsync(65) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index 8cf92d58..fd64c09a 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -4,6 +4,26 @@ import { cwd } from 'node:process' import { describe, expect, it } from 'vitest' describe('glass overlay material styles', () => { + it('reserves space below detached desktop navigation and follows the compact theme radius', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') + + expect(styles).toContain('--glass-v3-navigation-radius: var(--app-field-radius, 16px)') + expect(styles).toContain('--glass-v3-navigation-content-gap: 16px') + expect(styles).toContain('border-radius: var(--glass-v3-navigation-radius)') + expect(styles).toContain('--shell-floating-navbar-radius: var(--glass-v3-navigation-radius)') + expect(styles).toMatch(/var\(--layout-navbar-block-size\)\s*-\s*var\(--navbar-tab-height, 0px\)/u) + expect(styles).toContain('var(--glass-v3-navigation-content-gap)') + expect(styles).toContain('.layout-window-controls-overlay-shell') + expect(styles).not.toContain('border-radius: 16px') + }) + + it('keeps plugin logo tinting on the material formulas and displays complete logos', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') + + expect(styles).not.toMatch(/--plugin-card-banner-(?:tint|scrim)\s*:/u) + expect(styles).toMatch(/\.plugin-card__plugin-icon \.v-img__img\s*\{\s*object-fit:\s*contain;/u) + }) + it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss index 7a512e32..9407d23d 100644 --- a/src/styles/themes/_glass-v3.scss +++ b/src/styles/themes/_glass-v3.scss @@ -9,6 +9,10 @@ --glass-v3-sheen: clamp(0.04, calc(0.035 + var(--glass-reflection, 0.38) * 0.18), 0.2); --glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.14); --glass-v3-card-tint: 0; + // 导航使用随主题递增的紧凑半径档,比大块内容表面略收敛。 + --glass-v3-navigation-radius: var(--app-field-radius, 16px); + --glass-v3-navigation-inset: 8px; + --glass-v3-navigation-content-gap: 16px; --glass-v3-navigation-blur: clamp( 0.65px, calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px), @@ -122,13 +126,7 @@ } .layout-page-content .plugin-card__banner { - --plugin-card-banner-scrim: linear-gradient(rgba(23, 27, 32, 0.035), rgba(23, 27, 32, 0.1)); - --plugin-card-banner-tint: linear-gradient( - 125deg, - rgba(var(--plugin-card-effective-accent-rgb), 0.16), - rgba(var(--plugin-card-effective-accent-rgb), 0.045) 70% - ); - + // 品牌染色与吸收继续由各材质公式决定,轮廓只补充轻量的透光边缘。 border-block-end: 1px solid rgba(255, 255, 255, 0.12); } @@ -137,12 +135,18 @@ } .layout-page-content .plugin-card__plugin-icon { + background-color: rgba(255, 255, 255, 0.08); border-radius: var(--app-control-radius, 14px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 5px 12px rgba(0, 0, 0, 0.12); } + .layout-page-content .plugin-card__plugin-icon .v-img__img { + object-fit: contain; + padding: 3px; + } + .layout-page-content .media-card > .v-card-text { // 电影画面仍可检查,文字所在的下半区才承担阅读吸收。 background: linear-gradient( @@ -169,26 +173,34 @@ } .layout-vertical-nav { - border-radius: 16px; + border-radius: var(--glass-v3-navigation-radius); box-shadow: var(--glass-v3-shadow) !important; - block-size: calc(100% - 16px); - inline-size: calc(var(--glass-v3-nav-reserved-width) - 8px) !important; - inset-block-start: 8px; - inset-inline-start: 8px; + block-size: calc(100% - 2 * var(--glass-v3-navigation-inset)); + inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important; + inset-block-start: var(--glass-v3-navigation-inset); + inset-inline-start: var(--glass-v3-navigation-inset); overflow: clip; } &.layout-vertical-nav-collapsed .layout-vertical-nav.hovered { - inline-size: calc(#{variables.$layout-vertical-nav-width} - 8px) !important; + inline-size: calc(#{variables.$layout-vertical-nav-width} - var(--glass-v3-navigation-inset)) !important; } .layout-navbar { - border-radius: 16px !important; - inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 16px) !important; - inset-block-start: 8px !important; - inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + 8px) !important; + border-radius: var(--glass-v3-navigation-radius) !important; + inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important; + inset-block-start: var(--glass-v3-navigation-inset) !important; + inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important; overflow: clip; } + + // 标签栏由内容层另行预留;这里只补主导航内缩和下方间距,避免标签高度重复占位。 + .layout-page-content { + padding-block-start: calc( + var(--layout-navbar-block-size) - var(--navbar-tab-height, 0px) + var(--glass-v3-navigation-inset) + + var(--glass-v3-navigation-content-gap) + ); + } } @media (hover: hover) { @@ -272,6 +284,8 @@ ) body[data-theme='glass'] .layout-wrapper.layout-navbar-floating-eligible { + --shell-floating-navbar-radius: var(--glass-v3-navigation-radius); + .layout-navbar, .navbar-content-container { transition-property: diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts index 1b8b870d..df8a5d62 100644 --- a/src/utils/__tests__/glassNavbarRefraction.spec.ts +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -79,6 +79,10 @@ describe('createGlassNavbarDisplacementField', () => { { width: 68, height: 862, radius: 0 }, { width: 252, height: 846, radius: 16 }, { width: 60, height: 846, radius: 16 }, + { width: 252, height: 846, radius: 8 }, + { width: 252, height: 846, radius: 24 }, + { width: 60, height: 846, radius: 8 }, + { width: 60, height: 846, radius: 24 }, ])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => { for (const deformation of [0, 48, 100]) for (const translation of [0, 48, 100]) From 05ace80900005f1e86362748c473939f6e490a65 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 6 Sep 2026 22:46:24 +0800 Subject: [PATCH 2/5] feat(glass): unify soft contours and sidebar corner refraction --- .../theme/GlassNavbarRefractionDefs.vue | 11 +++- .../GlassNavbarRefractionDefs.spec.ts | 10 +++- .../__tests__/glassOverlayMaterial.spec.ts | 10 ++++ src/styles/themes/_glass-v3.scss | 52 ++++++++----------- .../__tests__/glassNavbarRefraction.spec.ts | 44 +++++++++++++++- src/utils/glassNavbarRefraction.ts | 37 ++++++++++--- 6 files changed, 123 insertions(+), 41 deletions(-) diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index d8412e95..ec60b130 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -3,6 +3,7 @@ import type { Ref } from 'vue' import { createGlassNavbarDisplacementMap, getGlassNavbarOpticalResponse, + getGlassSidebarOpticalResponse, NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP, } from '@/utils/glassNavbarRefraction' import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer' @@ -214,7 +215,13 @@ function readDisplacementGeometry(surface: NavigationSurface) { const width = Math.max(1, Math.round(bounds.width)) const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius - const optics = opticalResponse.value + const optics = + surface === 'sidebar' + ? getGlassSidebarOpticalResponse({ + deformation: settings.value.glassDeformationStrength, + translation: settings.value.glassTranslationStrength, + }) + : opticalResponse.value return { height, radius, @@ -241,7 +248,7 @@ async function syncDisplacementMap(surface: NavigationSurface) { try { if (state.cachedGeometry !== geometryKey) { if (state.failedGeometry === geometryKey) return - const map = createGlassNavbarDisplacementMap({ height, radius, width, optics }) + const map = createGlassNavbarDisplacementMap({ height, radius, width, optics, surface }) if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) { state.failedGeometry = geometryKey invalidateDisplacementMap(surface) diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index b4d41024..07ac95c2 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -1,7 +1,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue' -import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction' +import { createGlassNavbarDisplacementMap, getGlassSidebarOpticalResponse } from '@/utils/glassNavbarRefraction' import { ref } from 'vue' vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({ @@ -190,7 +190,13 @@ describe('GlassNavbarRefractionDefs', () => { expect.objectContaining({ width: 1423, height: 64, radius: 16 }), ) expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( - expect.objectContaining({ width: 260, height: 800, radius: 0 }), + expect.objectContaining({ + width: 260, + height: 800, + radius: 0, + surface: 'sidebar', + optics: getGlassSidebarOpticalResponse({ deformation: 48, translation: 48 }), + }), ) expect(shell.dataset.glassNavbarRefractionReady).toBe('false') expectReadyForSidebar(260) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index fd64c09a..fe84a23a 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -4,6 +4,16 @@ import { cwd } from 'node:process' import { describe, expect, it } from 'vitest' describe('glass overlay material styles', () => { + it('shares the soft contour between content cards and detached navigation', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') + + expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow)') + expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow)') + expect(styles).toMatch( + /&::before\s*\{[\s\S]*?border-radius: inherit;[\s\S]*?box-shadow: var\(--glass-v3-surface-edge\)/u, + ) + }) + it('reserves space below detached desktop navigation and follows the compact theme radius', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss index 9407d23d..9a547e1b 100644 --- a/src/styles/themes/_glass-v3.scss +++ b/src/styles/themes/_glass-v3.scss @@ -13,6 +13,7 @@ --glass-v3-navigation-radius: var(--app-field-radius, 16px); --glass-v3-navigation-inset: 8px; --glass-v3-navigation-content-gap: 16px; + --glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); --glass-v3-navigation-blur: clamp( 0.65px, calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px), @@ -45,6 +46,16 @@ } body[data-theme='glass'] { + .v-card, + .layout-navbar, + .layout-vertical-nav::before { + // 在表面上解析反光强度,卡片 hover 的局部参数不能被根节点预先求值。 + --glass-v3-surface-edge: + inset 1px 1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.3)), + inset -1px -1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.22)), + inset 0 0 5px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.1)); + } + // 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。 .layout-page-content .v-card:not( @@ -63,12 +74,7 @@ .dashboard-grid-content-measure > :first-child > .v-card { border-radius: var(--app-theme-surface-radius, 20px) !important; background: var(--glass-v3-card-background) !important; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), - inset 1px 0 0 rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.6)), - inset -1px -2px 2px rgba(0, 0, 0, 0.16), - inset 0 0 0 1px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.25)), - var(--glass-v3-shadow) !important; + box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow) !important; } .layout-page-content @@ -174,12 +180,21 @@ .layout-vertical-nav { border-radius: var(--glass-v3-navigation-radius); - box-shadow: var(--glass-v3-shadow) !important; + box-shadow: var(--glass-v3-navigation-shadow) !important; block-size: calc(100% - 2 * var(--glass-v3-navigation-inset)); inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important; inset-block-start: var(--glass-v3-navigation-inset); inset-inline-start: var(--glass-v3-navigation-inset); overflow: clip; + + &::before { + // 材质与采样外壳共用圆角,内沿高光必须绕四角延续,不能绘制直边后再裁断。 + border-radius: inherit; + border-inline-end: 0; + background: var(--glass-v3-card-background) !important; + // 固定导航共用柔和的掠射光,四角厚度由透镜承担,不叠加等宽硬质亮线。 + box-shadow: var(--glass-v3-surface-edge) !important; + } } &.layout-vertical-nav-collapsed .layout-vertical-nav.hovered { @@ -188,6 +203,7 @@ .layout-navbar { border-radius: var(--glass-v3-navigation-radius) !important; + box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow) !important; inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important; inset-block-start: var(--glass-v3-navigation-inset) !important; inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important; @@ -237,18 +253,6 @@ background: var(--glass-v3-card-background) !important; backdrop-filter: var(--glass-navbar-live-filter) !important; -webkit-backdrop-filter: var(--glass-navbar-live-filter) !important; - box-shadow: - inset 0 -1px 0 rgba(255, 255, 255, var(--glass-v3-rim)), - 0 8px 24px rgba(0, 0, 0, 0.1) !important; - } - - .layout-vertical-nav::before { - border-inline-end: 0; - background: var(--glass-v3-card-background) !important; - box-shadow: - inset -1px 0 0 rgba(255, 255, 255, var(--glass-v3-rim)), - inset -3px 0 4px rgba(255, 255, 255, 0.035), - 8px 0 24px rgba(0, 0, 0, 0.08) !important; } } @@ -266,16 +270,6 @@ } } - // 稳定背板已承担背景吸收,侧栏用明亮散射层呈现厚度,避免再覆盖一层深色渐变。 - html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] { - .layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before { - background: var(--glass-v3-card-background) !important; - box-shadow: - inset -1px 0 0 rgba(255, 255, 255, 0.32), - 8px 0 24px rgba(0, 0, 0, 0.07) !important; - } - } - // 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。 html[data-theme='glass']:is( [data-glass-appearance='clear'], diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts index df8a5d62..6133cc51 100644 --- a/src/utils/__tests__/glassNavbarRefraction.spec.ts +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createGlassNavbarDisplacementField, getGlassNavbarOpticalResponse, + getGlassSidebarOpticalResponse, supportsGlassNavbarLiveRefraction, } from '@/utils/glassNavbarRefraction' @@ -45,6 +46,16 @@ describe('getGlassNavbarOpticalResponse', () => { }) }) +describe('getGlassSidebarOpticalResponse', () => { + it.each([0, 50, 99, 100])('keeps the same cubic sliders and equal corner axes at %s', strength => { + const parameters = Object.freeze({ deformation: strength, translation: strength }) + const optics = getGlassSidebarOpticalResponse(parameters) + expect(optics.horizontalRatio).toBe(optics.verticalRatio) + expect(optics.horizontalRatio).toBeLessThanOrEqual(0.3) + expect(optics.translationPx).toBe(getGlassNavbarOpticalResponse(parameters).translationPx) + }) +}) + describe('createGlassNavbarDisplacementField', () => { function pixelAt(field: ReturnType, x: number, y: number) { const offset = (y * field.width + x) * 4 @@ -83,13 +94,19 @@ describe('createGlassNavbarDisplacementField', () => { { width: 252, height: 846, radius: 24 }, { width: 60, height: 846, radius: 8 }, { width: 60, height: 846, radius: 24 }, + ...[60, 252].flatMap(width => + [0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })), + ), ])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => { for (const deformation of [0, 48, 100]) for (const translation of [0, 48, 100]) for (const scale of [-22, -34]) { const field = createGlassNavbarDisplacementField({ ...geometry, - optics: getGlassNavbarOpticalResponse({ deformation, translation }), + optics: + 'surface' in geometry + ? getGlassSidebarOpticalResponse({ deformation, translation }) + : getGlassNavbarOpticalResponse({ deformation, translation }), }) const source = (x: number, y: number) => { const pixel = pixelAt(field, x, y) @@ -123,6 +140,31 @@ describe('createGlassNavbarDisplacementField', () => { } }) + it.each([8, 12, 16, 20, 24])('turns all four sidebar corners with one radial profile at radius %s', radius => { + const field = createGlassNavbarDisplacementField({ + width: 252, + height: 180, + radius, + surface: 'sidebar', + optics: getGlassSidebarOpticalResponse({ deformation: 50, translation: 0 }), + }) + for (let y = 0; y < radius; y += 1) + for (let x = 0; x < radius; x += 1) { + const topLeft = pixelAt(field, x, y) + const topRight = pixelAt(field, field.width - 1 - x, y) + const bottomLeft = pixelAt(field, x, field.height - 1 - y) + const bottomRight = pixelAt(field, field.width - 1 - x, field.height - 1 - y) + expect(topLeft[0] + topRight[0]).toBe(256) + expect(topLeft[2] + bottomLeft[2]).toBe(256) + expect(bottomRight[0]).toBe(topRight[0]) + expect(bottomRight[2]).toBe(bottomLeft[2]) + expect(pixelAt(field, y, x)[0]).toBe(topLeft[2]) + } + expect(pixelAt(field, 126, 2)[2]).toBeLessThan(128) + expect(pixelAt(field, 126, 177)[2]).toBeGreaterThan(128) + expect(pixelAt(field, 126, 90)).toEqual([128, 128, 128, 255]) + }) + it('keeps a fixed rectangle optically active without substituting a rounded corner', () => { const field = createGlassNavbarDisplacementField({ height: 800, diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts index ec2ff56e..835533b8 100644 --- a/src/utils/glassNavbarRefraction.ts +++ b/src/utils/glassNavbarRefraction.ts @@ -16,26 +16,38 @@ export interface GlassNavbarRefractionBrowserIdentity { } export interface GlassNavbarDisplacementGeometry { + /** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */ + surface?: 'navbar' | 'sidebar' /** 折射表面的实际 CSS 像素高度。 */ height: number /** 最终可见外轮廓的圆角半径。 */ radius: number /** 折射表面的实际 CSS 像素宽度。 */ width: number - /** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */ + /** 由当前生效滑杆计算的导航光学响应;省略时采用清透自然默认值。 */ optics?: GlassNavbarOpticalResponse } -/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */ +/** 导航局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */ export interface GlassNavbarOpticalResponse { /** 横向边缘峰值位移与轮廓带宽之比。 */ horizontalRatio: number - /** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */ + /** 纵向峰值位移与轮廓带宽之比;顶栏弱于横向,侧栏沿四边等向响应。 */ verticalRatio: number /** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */ translationPx: number } +/** 侧栏的阅读区由窄边带保护,圆角处两轴使用相同强度,避免转角时透镜厚度消失。 */ +export function getGlassSidebarOpticalResponse( + parameters: Pick, +): GlassNavbarOpticalResponse { + const navbar = getGlassNavbarOpticalResponse(parameters) + // 最大位移不超过带宽的 30%,为圆角内侧的取样回落保留单调余量。 + const ratio = (navbar.horizontalRatio / 0.42) * 0.3 + return { horizontalRatio: ratio, verticalRatio: ratio, translationPx: navbar.translationPx } +} + /** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */ export function getGlassNavbarOpticalResponse( parameters: Pick, @@ -108,7 +120,10 @@ export function createGlassNavbarDisplacementField({ height, radius, width, - optics = DEFAULT_NAVBAR_OPTICS, + surface = 'navbar', + optics = surface === 'sidebar' + ? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural')) + : DEFAULT_NAVBAR_OPTICS, }: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField { const pixelWidth = normalizePixelSize(width) const pixelHeight = normalizePixelSize(height) @@ -116,7 +131,11 @@ export function createGlassNavbarDisplacementField({ const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0 // 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。 const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX - const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) + // 侧栏边带不穿过圆角圆心,四角法线能连续转向且不会在内侧形成聚焦尖点。 + const maximumBand = + surface === 'sidebar' + ? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2) + : Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) for (let offset = 0; offset < pixels.length; offset += 4) { @@ -152,7 +171,10 @@ export function createGlassNavbarDisplacementField({ // 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。 const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64))) - const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand)))) + const verticalRamp = + surface === 'sidebar' + ? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64))) + : smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand)))) const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX if (pixelRadius === 0) { @@ -175,7 +197,8 @@ export function createGlassNavbarDisplacementField({ const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0 const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard)) - const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2 + // 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。 + const verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2 const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX const gradientX = roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) - From 35f54fcc35fdcda381afbae3a5b95298f5e32a26 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Sun, 6 Sep 2026 23:57:20 +0800 Subject: [PATCH 3/5] fix(glass): keep dashboard hover on the same material contour --- src/styles/__tests__/glassOverlayMaterial.spec.ts | 12 ++++++++++++ src/styles/themes/_glass-v3.scss | 3 +++ src/styles/themes/glass.scss | 12 ------------ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index fe84a23a..8a0f4abd 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -4,6 +4,18 @@ import { cwd } from 'node:process' import { describe, expect, it } from 'vitest' describe('glass overlay material styles', () => { + it('keeps dashboard hover on one contour instead of restoring legacy inset lines', () => { + const legacy = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + const surfaces = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') + + expect(legacy).not.toContain( + '.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card:hover', + ) + expect(surfaces).toMatch( + /--glass-v3-rim: clamp\(0\.2,[\s\S]*?box-shadow: var\(--glass-v3-surface-edge\), var\(--glass-v3-shadow\) !important/u, + ) + }) + it('shares the soft contour between content cards and detached navigation', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss index 9a547e1b..ace7a99b 100644 --- a/src/styles/themes/_glass-v3.scss +++ b/src/styles/themes/_glass-v3.scss @@ -238,6 +238,9 @@ .dashboard-grid-content-measure > :first-child > .v-card:hover { --glass-v3-rim: clamp(0.2, calc(0.22 + var(--glass-reflection, 0.38) * 0.36), 0.54); --glass-v3-shadow: 0 16px 34px rgba(0, 0, 0, 0.18); + + // 悬停沿用静态轮廓,只增强其受光和投影,不切换到独立的硬质描边。 + box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow) !important; } } } diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index f1470fa3..0e72cf45 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -448,18 +448,6 @@ html[data-theme='glass'] { 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; - } - } - .layout-navbar { border-color: var(--glass-border-raised) !important; -webkit-backdrop-filter: var(--glass-raised-backdrop-filter); From 851bcd8c827a024befc5134b55ee7a925e85060b Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Mon, 7 Sep 2026 05:17:10 +0800 Subject: [PATCH 4/5] feat(glass): extend blur-free panels and defer inactive GPU work --- src/@layouts/components/VerticalNavLayout.vue | 2 + .../__tests__/VerticalNavLayout.spec.ts | 6 + .../theme/GlassPanelRefractionDefs.vue | 506 ++++++++++++++++ .../GlassPanelRefractionDefs.spec.ts | 538 ++++++++++++++++++ .../__tests__/useGlassOpticalRenderer.spec.ts | 122 +++- src/composables/useGlassOpticalRenderer.ts | 166 +++++- .../__tests__/glassRippleDynamics.spec.ts | 47 +- src/rendering/glass/glassRippleDynamics.ts | 19 +- .../__tests__/glassOverlayMaterial.spec.ts | 19 +- src/styles/themes/_glass-v3.scss | 61 +- src/styles/themes/glass.scss | 49 +- .../__tests__/glassNavbarRefraction.spec.ts | 33 ++ src/utils/glassNavbarRefraction.ts | 83 ++- 13 files changed, 1549 insertions(+), 102 deletions(-) create mode 100644 src/components/theme/GlassPanelRefractionDefs.vue create mode 100644 src/components/theme/__tests__/GlassPanelRefractionDefs.spec.ts diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index eec7ef6f..9b126e52 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -3,6 +3,7 @@ import { useDisplay } from 'vuetify' import VerticalNav from '@layouts/components/VerticalNav.vue' import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue' import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue' +import GlassPanelRefractionDefs from '@/components/theme/GlassPanelRefractionDefs.vue' import { readThemeCustomizerSettings, THEME_CUSTOMIZER_CHANGE_EVENT, @@ -239,6 +240,7 @@ export default defineComponent({ }, [ navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null, + navbarRefractionMode === 'chromium' ? h(GlassPanelRefractionDefs) : null, fixedShellBackplateNode, verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), diff --git a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts index 3c1d5294..90f8cfc4 100644 --- a/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts +++ b/src/@layouts/components/__tests__/VerticalNavLayout.spec.ts @@ -108,6 +108,10 @@ vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({ default: { template: '' }, })) +vi.mock('@/components/theme/GlassPanelRefractionDefs.vue', () => ({ + default: { template: '' }, +})) + vi.mock('@layouts/components/VerticalNav.vue', () => ({ default: { template: '' }, })) @@ -198,6 +202,7 @@ describe('VerticalNavLayout shell states', () => { expect(goal1Wrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('goal1') expect(goal1Wrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(false) + expect(goal1Wrapper.find('[data-testid="panel-refraction-defs"]').exists()).toBe(false) goal1Wrapper.unmount() mocks.navbarRefractionSupported = true @@ -205,6 +210,7 @@ describe('VerticalNavLayout shell states', () => { expect(chromiumWrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('chromium') expect(chromiumWrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(true) + expect(chromiumWrapper.find('[data-testid="panel-refraction-defs"]').exists()).toBe(true) }) it('keeps the footer contract stable across App and drawer shells', async () => { diff --git a/src/components/theme/GlassPanelRefractionDefs.vue b/src/components/theme/GlassPanelRefractionDefs.vue new file mode 100644 index 00000000..4ab38e32 --- /dev/null +++ b/src/components/theme/GlassPanelRefractionDefs.vue @@ -0,0 +1,506 @@ + + + + + diff --git a/src/components/theme/__tests__/GlassPanelRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassPanelRefractionDefs.spec.ts new file mode 100644 index 00000000..1109180a --- /dev/null +++ b/src/components/theme/__tests__/GlassPanelRefractionDefs.spec.ts @@ -0,0 +1,538 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import GlassPanelRefractionDefs from '../GlassPanelRefractionDefs.vue' +import { createGlassNavbarDisplacementMap, createGlassPanelBackdropMap } from '@/utils/glassNavbarRefraction' + +vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({ + ...(await importOriginal()), + createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,panel'), + createGlassPanelBackdropMap: vi.fn(() => 'data:image/png;base64,backplate'), +})) + +const effectiveSettings = ref({ + glassAppearance: 'clear' as 'clear' | 'frosted' | 'tinted', + glassDeformationStrength: 48, + glassQuality: 'high' as 'balanced' | 'high' | 'css', + glassTransparencyStrength: 48, + glassTranslationStrength: 48, +}) +vi.mock('@/composables/useThemeCustomizer', () => ({ useEffectiveGlassSettings: () => effectiveSettings })) + +const CHROME_USER_AGENT = 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36' +const FIREFOX_USER_AGENT = 'Mozilla/5.0 Firefox/142.0' +const unsupportedStyleProperties = new Set(['backdrop-filter', '-webkit-backdrop-filter']) +const nativeSetProperty = CSSStyleDeclaration.prototype.setProperty +const nativeGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue +const nativeGetPropertyPriority = CSSStyleDeclaration.prototype.getPropertyPriority +const nativeRemoveProperty = CSSStyleDeclaration.prototype.removeProperty +const unsupportedStyleValues = new WeakMap>() + +describe('GlassPanelRefractionDefs', () => { + let shell: HTMLDivElement + let card: HTMLElement + let wrapper: ReturnType | undefined + let resize: ResizeObserverCallback | undefined + let intersect: IntersectionObserverCallback | undefined + let panelWidth: number + let visibility: DocumentVisibilityState + let focused: boolean + let browser: 'chrome' | 'firefox' + let reducedTransparency: boolean + let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }> + const disconnect = vi.fn() + const observe = vi.fn() + const unobserve = vi.fn() + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + panelWidth = 480 + visibility = 'visible' + focused = true + browser = 'chrome' + reducedTransparency = false + decodePending = [] + effectiveSettings.value = { + glassAppearance: 'clear', + glassDeformationStrength: 48, + glassQuality: 'high', + glassTransparencyStrength: 48, + glassTranslationStrength: 48, + } + + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility) + vi.spyOn(document, 'hasFocus').mockImplementation(() => focused) + vi.spyOn(navigator, 'userAgent', 'get').mockImplementation(() => + browser === 'chrome' ? CHROME_USER_AGENT : FIREFOX_USER_AGENT, + ) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + let left = 24 + let top = 120 + let width = panelWidth + let height = 240 + + if (this.classList.contains('layout-navbar')) { + left = 0 + top = 0 + width = 1200 + height = 64 + } else if (this.classList.contains('layout-vertical-nav')) { + left = 0 + top = 64 + width = 260 + height = 800 + } else if ( + this.classList.contains('glass-fixed-shell-backplate') || + this.classList.contains('glass-fixed-shell-backplate__layer') + ) { + left = 0 + top = 0 + width = 1200 + height = 900 + } + + return { + x: left, + y: top, + left, + top, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + } + }) + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => { + const radius = element.classList.contains('layout-vertical-nav') + ? 0 + : element.classList.contains('v-card') + ? 12 + : 16 + return { + borderTopLeftRadius: `${radius}px`, + getPropertyValue: (property: string) => (property === 'border-top-left-radius' ? `${radius}px` : ''), + } as unknown as CSSStyleDeclaration + }) + vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation(function ( + this: CSSStyleDeclaration, + property, + value, + priority = '', + ) { + if (unsupportedStyleProperties.has(property)) { + let values = unsupportedStyleValues.get(this) + if (!value) { + values?.delete(property) + return + } + if (!values) { + values = new Map() + unsupportedStyleValues.set(this, values) + } + values.set(property, { priority, value }) + return + } + nativeSetProperty.call(this, property, value, priority) + }) + vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyValue').mockImplementation(function ( + this: CSSStyleDeclaration, + property, + ) { + if (unsupportedStyleProperties.has(property)) return unsupportedStyleValues.get(this)?.get(property)?.value ?? '' + return nativeGetPropertyValue.call(this, property) + }) + vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyPriority').mockImplementation(function ( + this: CSSStyleDeclaration, + property, + ) { + if (unsupportedStyleProperties.has(property)) + return unsupportedStyleValues.get(this)?.get(property)?.priority ?? '' + return nativeGetPropertyPriority.call(this, property) + }) + vi.spyOn(CSSStyleDeclaration.prototype, 'removeProperty').mockImplementation(function ( + this: CSSStyleDeclaration, + property, + ) { + if (unsupportedStyleProperties.has(property)) { + const previous = unsupportedStyleValues.get(this)?.get(property)?.value ?? '' + unsupportedStyleValues.get(this)?.delete(property) + return previous + } + return nativeRemoveProperty.call(this, property) + }) + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + intersect = callback + } + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resize = callback + } + observe = observe + unobserve = unobserve + disconnect = disconnect + }, + ) + vi.stubGlobal('matchMedia', () => ({ + matches: reducedTransparency, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + vi.stubGlobal( + 'Image', + class { + src = '' + decode = vi.fn( + () => + new Promise((resolve, reject) => { + decodePending.push({ resolve, reject }) + }), + ) + }, + ) + + resetFixture() + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + shell?.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + function resetFixture(horizontal = true) { + wrapper?.unmount() + wrapper = undefined + shell?.remove() + + panelWidth = 480 + visibility = 'visible' + focused = true + browser = 'chrome' + reducedTransparency = false + effectiveSettings.value = { + glassAppearance: 'clear', + glassDeformationStrength: 48, + glassQuality: 'high', + glassTransparencyStrength: 48, + glassTranslationStrength: 48, + } + + document.documentElement.dataset.theme = 'glass' + document.documentElement.dataset.glassAppearance = effectiveSettings.value.glassAppearance + document.documentElement.dataset.glassQuality = effectiveSettings.value.glassQuality + + shell = document.createElement('div') + shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}` + shell.dataset.shellMode = 'desktop' + shell.innerHTML = ` +
+ +
+
+
+
+
+
+
+
+
+
+
+ ` + document.body.append(shell) + card = shell.querySelector('[data-card="eligible"]') as HTMLElement + } + + function mountPanel() { + wrapper = mount(GlassPanelRefractionDefs, { attachTo: shell }) + return wrapper + } + + function completePendingDecode() { + for (const pending of decodePending.splice(0)) pending.resolve() + } + + async function settle() { + await vi.advanceTimersByTimeAsync(65) + for (let attempt = 0; attempt < 8 && decodePending.length > 0; attempt += 1) { + completePendingDecode() + await flushPromises() + } + await flushPromises() + } + + function intersectCard(isIntersecting: boolean) { + const bounds = card.getBoundingClientRect() + intersect?.( + [ + { + target: card, + isIntersecting, + boundingClientRect: bounds, + intersectionRatio: isIntersecting ? 1 : 0, + intersectionRect: bounds, + rootBounds: bounds, + time: 0, + }, + ], + {} as IntersectionObserver, + ) + } + + function expectNoPanelFilter(element: HTMLElement) { + expect(element.style.getPropertyValue('backdrop-filter')).not.toContain('url(') + expect(element.style.getPropertyValue('-webkit-backdrop-filter')).not.toContain('url(') + expect(element.style.getPropertyValue('--glass-panel-filter')).not.toContain('url(') + expect(element.style.getPropertyValue('filter')).not.toContain('url(') + } + + it('requires glass desktop Chromium balanced/high visible focus gates', async () => { + for (const quality of ['balanced', 'high'] as const) { + resetFixture() + effectiveSettings.value.glassQuality = quality + document.documentElement.dataset.glassQuality = quality + vi.clearAllMocks() + mountPanel() + await settle() + + expect(createGlassNavbarDisplacementMap).toHaveBeenCalled() + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + } + + const cases = [ + { name: 'glass theme', apply: () => (document.documentElement.dataset.theme = 'dark') }, + { name: 'desktop shell', apply: () => (shell.dataset.shellMode = 'mobile') }, + { name: 'Chromium browser', apply: () => (browser = 'firefox') }, + { + name: 'non-CSS quality', + apply: () => { + effectiveSettings.value.glassQuality = 'css' + document.documentElement.dataset.glassQuality = 'css' + }, + }, + { name: 'visible document', apply: () => (visibility = 'hidden') }, + { name: 'focused document', apply: () => (focused = false) }, + { name: 'reduced transparency', apply: () => (reducedTransparency = true) }, + ] + + for (const { name, apply } of cases) { + resetFixture() + vi.clearAllMocks() + apply() + mountPanel() + await settle() + + expect(createGlassNavbarDisplacementMap, name).not.toHaveBeenCalled() + expect(createGlassPanelBackdropMap, name).not.toHaveBeenCalled() + expectNoPanelFilter(card) + } + }) + + it('waits for decode, enhances only top-level content cards, and leaves horizontal navigation untouched', async () => { + mountPanel() + await vi.advanceTimersByTimeAsync(65) + + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1) + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith( + expect.objectContaining({ height: 240, surface: 'panel', width: 480 }), + ) + expectNoPanelFilter(card) + + expect(card.dataset.glassPanelOwner).toMatch(/^glass-panel-/) + + completePendingDecode() + await flushPromises() + + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important') + expect(card.style.getPropertyValue('-webkit-backdrop-filter')).toContain('url(') + expectNoPanelFilter(shell.querySelector('[data-card="media"]') as HTMLElement) + expectNoPanelFilter(shell.querySelector('[data-card="overlay"]') as HTMLElement) + expectNoPanelFilter(shell.querySelector('.layout-navbar') as HTMLElement) + expectNoPanelFilter(shell.querySelector('.layout-vertical-nav') as HTMLElement) + expect(createGlassPanelBackdropMap).not.toHaveBeenCalled() + }) + + it('enhances site and plugin routes without requiring a dashboard', async () => { + shell.querySelector('.dashboard-grid')!.className = 'layout-page-content' + shell.querySelector('.dashboard-grid-content-measure')!.className = 'plugin-grid' + card.classList.add('plugin-card') + mountPanel() + await settle() + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + expectNoPanelFilter(shell.querySelector('[data-card="media"]') as HTMLElement) + expectNoPanelFilter(shell.querySelector('[data-card="overlay"]') as HTMLElement) + }) + + it('releases far-away filters and reuses decoded geometry when a card returns', async () => { + mountPanel() + await settle() + const calls = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length + intersectCard(false) + await settle() + expectNoPanelFilter(card) + expect(wrapper?.findAll('filter')).toHaveLength(0) + // 保留背景所有权,屏外表面不能反过来启动第二套静态 WebGL 材质。 + expect(card.hasAttribute('data-glass-panel-owner')).toBe(true) + intersectCard(true) + await settle() + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(calls) + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + }) + + it.each(['clear', 'tinted'] as const)( + 'keeps %s free of background diffusion in both enhanced tiers', + async appearance => { + for (const quality of ['balanced', 'high'] as const) { + resetFixture() + effectiveSettings.value.glassAppearance = appearance + effectiveSettings.value.glassQuality = quality + mountPanel() + await settle() + expect(wrapper?.findAll('feGaussianBlur')).toHaveLength(0) + expect(wrapper?.findAll('feDisplacementMap')).toHaveLength(1) + expect(card.style.getPropertyValue('backdrop-filter')).not.toContain('blur(') + } + }, + ) + + it('restores existing inline declarations and priorities when quality or theme exits', async () => { + for (const exit of ['quality', 'theme'] as const) { + resetFixture() + card.style.setProperty('backdrop-filter', 'blur(2px)', 'important') + card.style.setProperty('-webkit-backdrop-filter', 'saturate(80%)') + card.dataset.glassPanelRefraction = 'legacy' + mountPanel() + await settle() + + expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important') + expect(card.dataset.glassPanelRefraction).not.toBe('legacy') + + if (exit === 'quality') { + effectiveSettings.value.glassQuality = 'css' + document.documentElement.dataset.glassQuality = 'css' + } else { + document.documentElement.dataset.theme = 'dark' + } + await flushPromises() + await vi.advanceTimersByTimeAsync(65) + await flushPromises() + + expect(card.style.getPropertyValue('backdrop-filter')).toBe('blur(2px)') + expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important') + expect(card.style.getPropertyValue('-webkit-backdrop-filter')).toBe('saturate(80%)') + expect(card.style.getPropertyPriority('-webkit-backdrop-filter')).toBe('') + expect(card.dataset.glassPanelRefraction).toBe('legacy') + expect(card.style.getPropertyValue('backdrop-filter')).not.toContain('url(') + } + }) + + it('does not bind a late decode after the quality gate is disabled', async () => { + mountPanel() + await vi.advanceTimersByTimeAsync(65) + expect(decodePending).toHaveLength(1) + + effectiveSettings.value.glassQuality = 'css' + document.documentElement.dataset.glassQuality = 'css' + await flushPromises() + completePendingDecode() + await flushPromises() + + expectNoPanelFilter(card) + expect(wrapper?.findAll('filter')).toHaveLength(0) + }) + + it('drops pending work and leaves no filter after unmount', async () => { + mountPanel() + await vi.advanceTimersByTimeAsync(65) + expect(decodePending).toHaveLength(1) + + wrapper?.unmount() + wrapper = undefined + completePendingDecode() + await flushPromises() + + expectNoPanelFilter(card) + expect(card.hasAttribute('data-glass-panel-refraction')).toBe(false) + expect(card.hasAttribute('data-glass-panel-owner')).toBe(false) + expect(shell.querySelectorAll('filter')).toHaveLength(0) + expect(disconnect).toHaveBeenCalled() + }) + + it('does not let an obsolete decode failure remove the latest material', async () => { + mountPanel() + await vi.advanceTimersByTimeAsync(65) + const obsolete = decodePending.shift()! + effectiveSettings.value.glassDeformationStrength = 80 + await settle() + const applied = card.style.getPropertyValue('backdrop-filter') + expect(applied).toContain('url(') + obsolete.reject(new Error('obsolete image')) + await flushPromises() + expect(card.style.getPropertyValue('backdrop-filter')).toBe(applied) + expect(wrapper?.findAll('filter')).toHaveLength(1) + }) + + it('rebuilds the card map when observed geometry changes', async () => { + mountPanel() + await settle() + const initialCallCount = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length + + panelWidth = 620 + resize?.([], {} as ResizeObserver) + expectNoPanelFilter(card) + await settle() + + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(initialCallCount + 1) + expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( + expect.objectContaining({ height: 240, surface: 'panel', width: 620 }), + ) + expect(wrapper?.find('feImage').attributes('width')).toBe('620') + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + }) + + it('uses the stable frosted backplate for fixed navigation instead of navigation backdrop filters', async () => { + resetFixture(false) + effectiveSettings.value.glassAppearance = 'frosted' + document.documentElement.dataset.glassAppearance = 'frosted' + mountPanel() + await settle() + + const layer = shell.querySelector('.glass-fixed-shell-backplate__layer') as HTMLElement + const navbar = shell.querySelector('.layout-navbar') as HTMLElement + const sidebar = shell.querySelector('.layout-vertical-nav') as HTMLElement + + expect(createGlassPanelBackdropMap).toHaveBeenCalledTimes(1) + expect(createGlassPanelBackdropMap).toHaveBeenCalledWith( + expect.objectContaining({ + height: 900, + panels: expect.arrayContaining([ + expect.objectContaining({ height: 64, width: 1200 }), + expect.objectContaining({ height: 800, width: 260 }), + ]), + width: 1200, + }), + ) + expect(layer.style.getPropertyValue('filter')).toContain('url(') + expect(navbar.style.getPropertyValue('--glass-panel-filter')).toBe('') + expect(sidebar.style.getPropertyValue('--glass-panel-filter')).toBe('') + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1) + expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(') + expect(wrapper?.findAll('feGaussianBlur')).toHaveLength(2) + }) +}) diff --git a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts index 203c474e..06236842 100644 --- a/src/composables/__tests__/useGlassOpticalRenderer.spec.ts +++ b/src/composables/__tests__/useGlassOpticalRenderer.spec.ts @@ -2049,6 +2049,53 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('hands only native-owned surfaces their static background while preserving shared dynamics', async () => { + const three = await import('three') + const navbar = appendOpticalSurface('layout-navbar', { height: 64, width: 600, x: 20, y: 20 }) + const assistant = appendOpticalSurface('agent-assistant-panel', { height: 240, width: 250, x: 700, y: 120 }) + const root = document.createElement('div') + root.className = 'app-wrapper' + root.append(navbar, assistant) + document.body.append(root) + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + quality: ref('high'), + routeKey: ref('/dashboard'), + surfaceSpace: 'fixed', + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + const getUniforms = () => { + const scene = render.mock.calls.at(-1)?.[0] as unknown as { + children: Array<{ + material: { + uniforms: { + uRectCount: { value: number } + uSurfaceBaseWeights: { value: number[] } + uSurfaceDynamics: { value: number[] } + } + } + }> + } + return scene.children[0].material.uniforms + } + expect(getUniforms().uRectCount.value).toBe(2) + expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2)).toEqual([1, 1]) + navbar.dataset.glassPanelRefraction = 'glass-panel-ready' + await vi.waitFor(() => expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2).sort()).toEqual([0, 1])) + expect(getUniforms().uSurfaceDynamics.value.slice(0, 2)).toEqual([1, 1]) + navbar.removeAttribute('data-glass-panel-refraction') + await vi.waitFor(() => expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2)).toEqual([1, 1])) + scope.stop() + }) + it('shares dynamics across nested hover cards without allocating another material slot', async () => { vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200) vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800) @@ -2880,35 +2927,49 @@ describe('glass optical surface discovery', () => { scope.stop() }) - it('keeps an initially unfocused visible renderer ready without drawing until focus resumes it', async () => { + it('defers all GPU preparation while initially unfocused and uses the latest configuration on focus', async () => { const three = await import('three') const canvas = document.createElement('canvas') const visibilityState: DocumentVisibilityState = 'visible' vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState) documentHasFocus = false const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const compile = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync') + const upload = vi.spyOn(three.WebGLRenderer.prototype, 'initTexture') + const appearance = ref<'clear' | 'frosted'>('clear') + const quality = ref<'balanced' | 'high'>('balanced') const scope = effectScope() const renderer = scope.run(() => useGlassOpticalRenderer({ active: ref(true), - appearance: ref('clear'), + appearance, canvas: ref(canvas), - quality: ref('balanced'), + quality, routeKey: ref('/dashboard'), tintColor: ref('#8D51F9'), wallpaperUrl: ref('https://example.com/wallpaper.jpg'), }), ) - await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + await nextTick() + appearance.value = 'frosted' + quality.value = 'high' + await nextTick() expect(render).not.toHaveBeenCalled() + expect(compile).not.toHaveBeenCalled() + expect(upload).not.toHaveBeenCalled() documentHasFocus = true window.dispatchEvent(new Event('focus')) - await nextTick() - await Promise.resolve() + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) expect(render).toHaveBeenCalled() + expect(compile).toHaveBeenCalled() + expect(upload).toHaveBeenCalled() + const scene = render.mock.calls.at(-1)?.[0] as unknown as { children: Array<{ material: ShaderMaterial }> } + const material = scene.children[0].material + expect(material.uniforms.uAppearance.value).toBe(2) + expect(material.uniforms.uQuality.value).toBe(1) scope.stop() }) @@ -3572,7 +3633,7 @@ describe('glass optical surface discovery', () => { const three = await import('three') const canvas = document.createElement('canvas') vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') - documentHasFocus = false + documentHasFocus = true const scope = effectScope() const renderer = scope.run(() => useGlassOpticalRenderer({ @@ -3590,6 +3651,7 @@ describe('glass optical surface discovery', () => { const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss') vi.useFakeTimers() + documentHasFocus = false window.dispatchEvent(new Event('blur')) await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS) @@ -4049,6 +4111,41 @@ describe('glass optical surface discovery', () => { scope.stop() }) + it('does not draw the main material before its asynchronous compilation completes', async () => { + const three = await import('three') + let finish: (() => void) | undefined + const compile = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce( + scene => + new Promise(resolve => { + finish = () => resolve(scene) + }), + ) + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') + const scope = effectScope() + const renderer = scope.run(() => + useGlassOpticalRenderer({ + active: ref(true), + appearance: ref('clear'), + canvas: ref(document.createElement('canvas')), + dynamicsMode: ref('off'), + quality: ref('balanced'), + routeKey: ref('/dashboard'), + tintColor: ref('#8D51F9'), + wallpaperUrl: ref('https://example.com/wallpaper.jpg'), + }), + ) + try { + await vi.waitFor(() => expect(compile).toHaveBeenCalledOnce()) + expect(render).not.toHaveBeenCalled() + finish?.() + await vi.waitFor(() => expect(renderer?.state.value).toBe('ready')) + expect(render).toHaveBeenCalled() + } finally { + finish?.() + scope.stop() + } + }) + it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => { const three = await import('three') const canvas = document.createElement('canvas') @@ -4060,6 +4157,10 @@ describe('glass optical surface discovery', () => { }), ) const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose') + const disposeRenderer = vi.spyOn(three.WebGLRenderer.prototype, 'dispose') + const disposeMaterial = vi.spyOn(three.ShaderMaterial.prototype, 'dispose') + const forceContextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss') + const render = vi.spyOn(three.WebGLRenderer.prototype, 'render') const addWindowListener = vi.spyOn(window, 'addEventListener') const addCanvasListener = vi.spyOn(canvas, 'addEventListener') const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) => @@ -4083,10 +4184,17 @@ describe('glass optical surface discovery', () => { expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0) expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1) const baselineDisposeCalls = disposeTarget.mock.calls.length + const baselineRenderCalls = render.mock.calls.length scope.stop() + expect(forceContextLoss).toHaveBeenCalledOnce() + expect(disposeRenderer).not.toHaveBeenCalled() + expect(disposeMaterial).not.toHaveBeenCalled() ;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D) await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2)) + expect(disposeRenderer).toHaveBeenCalledOnce() + expect(disposeMaterial).toHaveBeenCalledTimes(2) + expect(render).toHaveBeenCalledTimes(baselineRenderCalls) expect(ResizeObserverMock.instances).toHaveLength(0) expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0) diff --git a/src/composables/useGlassOpticalRenderer.ts b/src/composables/useGlassOpticalRenderer.ts index 13ff1e47..ceb94728 100644 --- a/src/composables/useGlassOpticalRenderer.ts +++ b/src/composables/useGlassOpticalRenderer.ts @@ -4,6 +4,7 @@ import type { Color, IUniform, Mesh, + Object3D, OrthographicCamera, Scene, ShaderMaterial, @@ -314,6 +315,8 @@ interface GlassRendererUniforms extends Record { uRects: IUniform uSurfaceWeights: IUniform uSurfaceDynamics: IUniform + /** 原生材质已持有背景的表面不重复绘制静态壁纸,但继续消费共享动态场。 */ + uSurfaceBaseWeights: IUniform uPreviousTexture: IUniform uPreviousFrostedTexture: IUniform uTexture: IUniform @@ -344,6 +347,8 @@ interface GlassRendererResources { } interface GlassFrostPrefilterResources { + /** 编译与释放预滤材质的 WebGL 资源所有者。 */ + owner: GlassRendererResources material: ShaderMaterial mesh: Mesh scene: Scene @@ -573,6 +578,7 @@ uniform vec4 uRects[8]; uniform vec4 uRadii[8]; uniform float uSurfaceWeights[8]; uniform float uSurfaceDynamics[8]; +uniform float uSurfaceBaseWeights[8]; uniform int uRectCount; uniform float uAppearance; uniform float uBackgroundVisibility; @@ -814,6 +820,7 @@ vec2 softLimitDynamicRefraction(vec2 refraction) { void main() { float mask = 0.0; + float baseMask = 0.0; float edge = 0.0; float caustic = 0.0; float directionalReflection = 0.0; @@ -869,6 +876,7 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD} vec4 rect = uRects[i]; float surfaceDynamic = uSurfaceDynamics[i]; + float surfaceBase = uSurfaceBaseWeights[i]; vec2 local = (vUv - rect.xy) / rect.zw; float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i]; if (rectMask <= 0.0) continue; @@ -892,18 +900,19 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD} ${GLASS_FLUID_FRAGMENT_SURFACE_SHAPE} float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality); ${GLASS_FLUID_FRAGMENT_SURFACE_OPTICS} - staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic; + staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic * surfaceBase; ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} dynamicRefraction += rippleRefraction * rippleMode * rectMask * surfaceDynamic * interactionMask; - edge = max(edge, edgeResponse * rectMask * surfaceDynamic); + edge = max(edge, edgeResponse * rectMask * surfaceDynamic * surfaceBase); caustic = max(caustic, localCaustic); caustic = max( caustic, rippleGradientEnergy * rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask ); - directionalReflection = max(directionalReflection, localDirectionalReflection); - topPrism = max(topPrism, localTopPrism); - backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption); + // 原生表面已有静态轮廓;交互只叠加局部位移与动态焦散,不重新点亮另一套静态棱边。 + directionalReflection = max(directionalReflection, localDirectionalReflection * surfaceBase); + topPrism = max(topPrism, localTopPrism * surfaceBase); + backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption * surfaceBase); materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask); materialEnergy = max( materialEnergy, @@ -915,12 +924,18 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} ); dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask); mask = max(mask, rectMask); + baseMask = max(baseMask, rectMask * uSurfaceBaseWeights[i]); } if (mask <= 0.0) discard; - float contentProtection = getContentProtection(coverUv(vUv + staticRefraction)); - dynamicRefraction *= contentProtection; + float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36); + bool dynamicsOnlyOutput = uDynamicsOnly > 0.5 || baseMask <= 0.0; + // 原生材质持有静态背景;无动态能量的像素不重复采样或输出另一套轮廓。 + if (dynamicsOnlyOutput && dynamicsPresence <= 0.0) discard; + if (dot(dynamicRefraction, dynamicRefraction) > 0.0) { + dynamicRefraction *= getContentProtection(coverUv(vUv + staticRefraction)); + } // 高光足迹与壁纸位移强度独立校准,收紧反馈范围不能同步削弱三项动态参数。 dynamicRefraction *= 1.2; dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction); @@ -1010,6 +1025,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} caustic * proceduralCausticAlpha ) * uReflectionStrength; + if (dynamicsOnlyOutput) proceduralAlpha *= clamp(dynamicsPresence, 0.0, 1.0); gl_FragColor = vec4(proceduralHighlight, proceduralAlpha); return; } @@ -1028,8 +1044,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} refracted = mix(refracted, highlight, reflectionMix); refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget; - if (uDynamicsOnly > 0.5) { - float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36); + if (dynamicsOnlyOutput) { float dynamicsAlpha = clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82); gl_FragColor = vec4(refracted, dynamicsAlpha); @@ -1039,7 +1054,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION} gl_FragColor = vec4( refracted, clamp( - mask * + baseMask * ( materialAlpha + ( @@ -1240,6 +1255,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const failedWallpaperPreparationKey = ref('') let three: ThreeModule | null = null let resources: GlassRendererResources | null = null + const pendingCompilations = new WeakMap>>() + const retiredResources = new WeakSet() + const compiledMainScenes = new WeakSet() let fluidDynamics: GlassFluidDynamics | null = null let rippleResources: GlassRippleDynamics | null = null let frostPrefilterResources: GlassFrostPrefilterResources | null = null @@ -1333,6 +1351,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) let resumeVersion = 0 // 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。 let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus() + let preparationDeferred = false let dynamicsGeneration = 0 const presentationSpace = options.surfaceSpace ?? 'fixed' const usesDynamicsOnly = () => @@ -1344,6 +1363,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden' } + /** 后台只保留最新配置;恢复时合并重建,避免准备完的纹理和波场立即被释放。 */ + function canPrepareResources() { + if (canPresentFrame()) return true + preparationDeferred = true + return false + } + /** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */ function syncWallpaperSamplingMode() { if (!resources) return @@ -1622,12 +1648,34 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame) } + /** Three 会在异步检查中读取材质的 program;该检查完成前不能释放其 properties。 */ + async function compileOwnedScene(owner: GlassRendererResources, scene: Object3D) { + if (retiredResources.has(owner)) throw new Error('Glass renderer was retired before compilation') + const pending = owner.renderer.compileAsync(scene, owner.camera) + const work = pendingCompilations.get(owner) ?? new Set>() + work.add(pending) + pendingCompilations.set(owner, work) + try { + await pending + if (scene === owner.scene && !retiredResources.has(owner)) compiledMainScenes.add(owner) + } finally { + work.delete(pending) + } + } + + function disposeAfterCompilation(owner: GlassRendererResources, dispose: () => void) { + const pending = pendingCompilations.get(owner) + if (pending?.size) void Promise.allSettled([...pending]).then(dispose) + else dispose() + } + /** 释放壁纸准备阶段复用的预滤 shader;活动低通纹理由各自 RenderTarget 单独持有。 */ function disposeFrostPrefilterResources() { if (!frostPrefilterResources) return - frostPrefilterResources.material.dispose() + const retired = frostPrefilterResources frostPrefilterResources = null + disposeAfterCompilation(retired.owner, () => retired.material.dispose()) } /** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */ @@ -1651,13 +1699,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const mesh = new three.Mesh(resources.geometry, material) mesh.frustumCulled = false scene.add(mesh) - frostPrefilterResources = { material, mesh, scene, uniforms } + frostPrefilterResources = { owner: resources, material, mesh, scene, uniforms } return frostPrefilterResources } /** 壁纸上传时执行两次 separable blur,常态只保留既定分辨率的低通 RenderTarget。 */ async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number, targetLongEdge: number) { + if (!canPrepareResources()) return null if (!resources || !three) return null const ownerResources = resources @@ -1681,8 +1730,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) try { ownerResources.renderer.initTexture(texture) - await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera) - if (resources !== ownerResources) { + await compileOwnedScene(ownerResources, prefilter.scene) + if (resources !== ownerResources || !canPrepareResources()) { outputTarget.dispose() return null } @@ -1759,6 +1808,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) /** 只在水漾被选中时为当前 context 编译并分配独占 ping-pong 场。 */ async function syncRippleResources() { if (!resources || !three) return + if (!canPrepareResources()) return if (!hasRippleCapability()) { disposeRippleResources() return @@ -1778,9 +1828,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) three, viewportHeight: window.innerHeight, viewportWidth: window.innerWidth, + compile: scene => compileOwnedScene(ownerResources, scene), + isCurrent: () => + generation === dynamicsGeneration && + resources === ownerResources && + hasRippleCapability() && + canPrepareResources(), }) } catch (error) { - if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) return + if ( + generation !== dynamicsGeneration || + resources !== ownerResources || + !hasRippleCapability() || + !canPrepareResources() + ) + return throw error } if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) { @@ -1801,6 +1863,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) /** 模式切换时同步释放旧策略、清空输入历史并在资源就绪后恢复订阅。 */ async function syncDynamicsMode() { if (!resources) return + if (!canPrepareResources()) return interactionAnimating = false activeTouchIdentifier = null @@ -1845,7 +1908,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } function renderFrame(timestamp = performance.now(), advanceFlow = true) { - if (!resources || !canPresentFrame()) return + // 首次 resize/observer 只能同步几何,不能在异步预编译前触发同步材质编译。 + if (!resources || !canPresentFrame() || !compiledMainScenes.has(resources)) return updateWallpaperTransition(timestamp) if (fluidDynamics && advanceFlow) { @@ -1923,6 +1987,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const uniformRadii = resources.uniforms.uRadii.value const uniformWeights = resources.uniforms.uSurfaceWeights.value const uniformDynamics = resources.uniforms.uSurfaceDynamics.value + const uniformBaseWeights = resources.uniforms.uSurfaceBaseWeights.value const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner)) const transitionWeights = outgoingSurface ? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS) @@ -1952,6 +2017,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) const nestedInteractionAvailable = !slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key) uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1 + uniformBaseWeights[index] = + slot && + !slot.key.hasAttribute('data-glass-panel-owner') && + !slot.key.hasAttribute('data-glass-panel-refraction') + ? 1 + : 0 } resources.uniforms.uRectCount.value = normalized.length @@ -3108,8 +3179,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) await nextTick() if (version !== resumeVersion || !canResume()) return - if (!resources) { - await initializeRenderer() + if (!resources || preparationDeferred) { + await initializeRenderer(false) if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate() return } @@ -3126,6 +3197,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) animationFrame = requestAnimationFrame(renderInteractionFrame) } scheduleWallpaperTransition() + preparePendingWallpaper() if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate() })() @@ -3283,7 +3355,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) observedMutationRoots.add(root) surfaceMutationObserver?.observe(root, { - attributeFilter: ['data-glass-optical-boundary', 'data-glass-optical-mode'], + attributeFilter: [ + 'data-glass-optical-boundary', + 'data-glass-optical-mode', + 'data-glass-panel-refraction', + 'data-glass-panel-owner', + ], attributes: true, childList: true, subtree, @@ -3293,6 +3370,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) surfaceMutationObserver = new MutationObserver(mutations => { // Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。 observeMutationRoot(document.querySelector('.v-overlay-container'), true) + if ( + mutations.some( + mutation => + mutation.attributeName === 'data-glass-panel-owner' && + mutation.target instanceof Element && + mutation.target.hasAttribute('data-glass-panel-owner'), + ) + ) { + // 背景所有权交接无需等待几何稳定采样,避免同一帧出现两套静态材质。 + writeSurfaceUniforms() + scheduleFrame() + } if (!mutationTouchesOpticalSurface(mutations)) return interactionClipMembershipDirty = true @@ -3388,6 +3477,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } function disposeRenderer(releaseContext = true) { + if (resources) retiredResources.add(resources) resumeVersion += 1 loadVersion += 1 prepareVersion += 1 @@ -3458,11 +3548,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) disposeRippleResources() disposeFrostPrefilterResources() if (resources) { - resources.geometry.dispose() - resources.material.dispose() - resources.renderer.dispose() - if (releaseContext) resources.renderer.forceContextLoss() + const retired = resources resources = null + // KHR 在 context loss 后把编译状态报告为完成,原生轮询可正常退出,再安全释放缓存。 + if (releaseContext) retired.renderer.forceContextLoss() + disposeAfterCompilation(retired, () => { + retired.geometry.dispose() + retired.material.dispose() + retired.renderer.dispose() + }) } delete document.documentElement.dataset.glassRendererState @@ -3479,6 +3573,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) /** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */ async function refreshWallpaper(message: string, beforeActivate?: () => void) { + if (!canPrepareResources()) return const version = ++loadVersion const retainsActiveTexture = Boolean(resources && activeTexture) if (!retainsActiveTexture) updateRendererState('loading') @@ -3696,6 +3791,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) releasePreparedWallpaper() clearPreparedWallpaperFailure() if (!url || !resources || contextRecoveryPending || url === toValue(options.wallpaperUrl)) return + if (!canPrepareResources()) return const preparationKey = getWallpaperPreparationKey(url) try { @@ -3706,7 +3802,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) !resources || contextRecoveryPending || preparationKey !== prepared.preparationKey || - preparationKey !== getWallpaperPreparationKey(url) + preparationKey !== getWallpaperPreparationKey(url) || + !canPrepareResources() ) { disposeWallpaperResources(prepared.texture, prepared.frostedTarget) return @@ -3714,7 +3811,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) resources.renderer.initTexture(prepared.texture) recordGlassRendererTiming(presentationSpace, 'prepare-compile-start') - await resources.renderer.compileAsync(resources.scene, resources.camera) + await compileOwnedScene(resources, resources.scene) recordGlassRendererTiming(presentationSpace, 'prepare-compile-ready') if ( version !== prepareVersion || @@ -3742,6 +3839,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) async function loadWallpaper(url: string, version: number, beforeActivate?: () => void) { if (!resources || !three || !url) return + if (!canPrepareResources()) return const prepared = await createWallpaperTexture(url) if (!prepared) return @@ -3749,20 +3847,22 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) version !== loadVersion || !resources || contextRecoveryPending || - prepared.preparationKey !== getWallpaperPreparationKey(url) + prepared.preparationKey !== getWallpaperPreparationKey(url) || + !canPrepareResources() ) { disposeWallpaperResources(prepared.texture, prepared.frostedTarget) return } recordGlassRendererTiming(presentationSpace, 'compile-start') - await resources.renderer.compileAsync(resources.scene, resources.camera) + await compileOwnedScene(resources, resources.scene) recordGlassRendererTiming(presentationSpace, 'compile-ready') if ( version !== loadVersion || !resources || contextRecoveryPending || - prepared.preparationKey !== getWallpaperPreparationKey(url) + prepared.preparationKey !== getWallpaperPreparationKey(url) || + !canPrepareResources() ) { disposeWallpaperResources(prepared.texture, prepared.frostedTarget) return @@ -3901,6 +4001,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) } async function initializeRenderer(releaseContext = true) { + if (!canPrepareResources()) return + preparationDeferred = false recordGlassRendererTiming(presentationSpace, 'initialize-start') disposeRenderer(releaseContext) if (!toValue(options.active) || !options.canvas.value) return @@ -3916,7 +4018,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) try { three = await import('three') recordGlassRendererTiming(presentationSpace, 'three-ready') - if (version !== loadVersion || !options.canvas.value) return + if (version !== loadVersion || !options.canvas.value || !canPrepareResources()) return const Vector4Class = three.Vector4 const canvas = options.canvas.value const context = prepareGlassWebGLContext(canvas) @@ -3974,6 +4076,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) }, uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) }, uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) }, + uSurfaceBaseWeights: { value: Array.from({ length: 8 }, () => 1) }, uPreviousTexture: { value: null }, uPreviousFrostedTexture: { value: null }, uTexture: { value: null }, @@ -4018,7 +4121,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) version !== loadVersion || resources !== ownerResources || !toValue(options.active) || - options.canvas.value !== canvas + options.canvas.value !== canvas || + !canPrepareResources() ) { return } @@ -4115,6 +4219,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) () => toValue(options.appearance), async (appearance, previousAppearance) => { if (!resources) return + if (!canPrepareResources()) return const applyAppearance = () => { if (!resources || toValue(options.appearance) !== appearance) return @@ -4170,6 +4275,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) () => toValue(options.quality), async (quality, previousQuality) => { if (!resources) return + if (!canPrepareResources()) return const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey)) const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey)) diff --git a/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts b/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts index 325d513a..a549d00e 100644 --- a/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts +++ b/src/rendering/glass/__tests__/glassRippleDynamics.spec.ts @@ -153,7 +153,7 @@ function createRippleHarness( } as unknown as typeof import('three') return { - create: () => + create: (overrides: Partial[0]> = {}) => createGlassRippleDynamics({ camera: {} as never, geometry: {} as never, @@ -162,6 +162,7 @@ function createRippleHarness( three, viewportHeight: 800, viewportWidth: 1200, + ...overrides, }), renderer, snapshots, @@ -174,6 +175,50 @@ beforeEach(() => { }) describe('glass ripple dynamics', () => { + it('waits for owner compilation before rendering the initial neutral field', async () => { + let finish: (() => void) | undefined + const compile = vi.fn( + () => + new Promise(resolve => { + finish = resolve + }), + ) + const harness = createRippleHarness() + const creation = harness.create({ compile, isCurrent: () => true }) + + expect(compile).toHaveBeenCalledOnce() + expect(harness.renderer.render).not.toHaveBeenCalled() + + finish?.() + const dynamics = await creation + + expect(harness.renderer.render).toHaveBeenCalledTimes(2) + expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true) + dynamics.dispose() + }) + + it('retains pending material until compilation finishes and skips superseded initialization', async () => { + let finish: (() => void) | undefined + let current = true + const compile = vi.fn( + () => + new Promise(resolve => { + finish = resolve + }), + ) + const harness = createRippleHarness() + const creation = harness.create({ compile, isCurrent: () => current }) + const result = expect(creation).rejects.toThrow('superseded') + const initialRenders = harness.renderer.render.mock.calls.length + current = false + expect(FakeShaderMaterial.instances[0].dispose).not.toHaveBeenCalled() + finish?.() + await result + expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce() + expect(harness.renderer.render).toHaveBeenCalledTimes(initialRenders) + expect(harness.renderer.compileAsync).not.toHaveBeenCalled() + }) + it('uses one bounded half-float ping-pong field when the renderer supports it', async () => { const harness = createRippleHarness() const dynamics = await harness.create() diff --git a/src/rendering/glass/glassRippleDynamics.ts b/src/rendering/glass/glassRippleDynamics.ts index 0b5f28f9..6090b488 100644 --- a/src/rendering/glass/glassRippleDynamics.ts +++ b/src/rendering/glass/glassRippleDynamics.ts @@ -1,6 +1,7 @@ import type { BufferGeometry, IUniform, + Object3D, OrthographicCamera, Texture, Vector2, @@ -71,6 +72,10 @@ interface CreateGlassRippleDynamicsOptions { three: ThreeModule viewportHeight: number viewportWidth: number + /** 由 owner 跟踪的异步编译,确保编译检查结束前资源保持有效。 */ + compile?: (scene: Object3D) => Promise + /** 代次失效后不再初始化或呈现波场。 */ + isCurrent?: () => boolean } const RIPPLE_VERTEX_SHADER = ` @@ -233,6 +238,8 @@ export async function createGlassRippleDynamics( let clearOnNextFrame = false let fieldActive = false let disposed = false + // 初次尺寸准备只能更新 target 和 uniform;GPU 中性场必须等 owner 编译完成后再写入。 + let compilationSettled = false const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType const createTarget = () => { @@ -332,7 +339,7 @@ export async function createGlassRippleDynamics( } uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height) uniforms.uViewportSize.value.set(viewportWidth, viewportHeight) - reset() + if (compilationSettled) reset() return true } @@ -365,10 +372,12 @@ export async function createGlassRippleDynamics( } try { - const initializedByResize = resize(viewportWidth, viewportHeight) - await renderer.compileAsync(scene, camera) - if (disposed) throw new Error('Ripple resources were disposed during compilation') - if (!initializedByResize) reset() + resize(viewportWidth, viewportHeight) + await (options.compile ? options.compile(scene) : renderer.compileAsync(scene, camera)) + if (disposed || options.isCurrent?.() === false) + throw new Error('Ripple resources were superseded during compilation') + compilationSettled = true + reset() } catch (error) { material.dispose() readTarget.dispose() diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index 8a0f4abd..e918aacf 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -49,8 +49,9 @@ describe('glass overlay material styles', () => { it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') - expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)') - expect(styles.match(/--glass-overlay-blur:\s*var\(--glass-overlay-clarity-blur, 6px\)/g)).toHaveLength(2) + expect(styles).toContain('calc(0.58 + var(--glass-surface-density, 0.62) * 0.18)') + expect(styles).toContain('calc(0.58 + var(--glass-surface-density, 0.72) * 0.18)') + expect(styles.match(/--glass-overlay-blur:\s*0px/g)).toHaveLength(2) expect(styles).toContain('--glass-overlay-saturate: 115%') expect(styles).toContain('--glass-overlay-saturate: 120%') expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)') @@ -282,10 +283,10 @@ describe('glass overlay material styles', () => { /\.layout-vertical-nav\s*\{[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-sidebar-live-filter\);[\s\S]*?background-image:\s*var\(--glass-sheen\)/, ) expect(styles).toMatch( - /\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/, + /\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\):not\(\[data-glass-panel-refraction\]\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/, ) expect(styles).toMatch( - /\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/, + /\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\):not\(\[data-glass-panel-refraction\]\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/, ) expect(styles).toMatch( /&\[data-glass-appearance='frosted'\]\s*\{[\s\S]*?\.layout-vertical-nav::before\s*\{[\s\S]*?var\(--glass-sidebar-absorption-start\)[\s\S]*?var\(--glass-sidebar-absorption-end\)[\s\S]*?var\(--glass-sidebar-edge-opacity\)/, @@ -343,7 +344,7 @@ describe('glass overlay material styles', () => { it('shares the same light frost when glass navbars overlap scrolled content', () => { const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') - expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%)') + expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: saturate(115%)') expect(styles).toMatch( /:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-wrapper\.window-scrolled\.layout-navbar-fixed \.layout-navbar,[\s\S]*?backdrop-filter:\s*var\(--glass-navbar-scrolled-backdrop-filter\)\s*!important;/, ) @@ -504,9 +505,7 @@ describe('glass overlay material styles', () => { ) expect(baseMaterialRule).toContain('var(--glass-background-visibility, 0.58)') expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)') - expect(baseMaterialRule).toContain('--glass-navbar-blur: clamp(') - expect(baseMaterialRule).toContain('0.62px + var(--glass-surface-density, 0.62) * 0.8px') - expect(baseMaterialRule).toContain('- var(--glass-background-visibility, 0.58) * 0.25px') + expect(baseMaterialRule).not.toContain('blur(') expect(baseMaterialRule).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)') expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(') expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(') @@ -515,7 +514,7 @@ describe('glass overlay material styles', () => { expect(baseMaterialRule).toContain( '--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18)', ) - expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(var(--glass-navbar-blur))') + expect(baseMaterialRule).toContain('--glass-navbar-live-filter: saturate(var(--glass-navbar-saturation))') expect(baseMaterialRule).toContain('brightness(var(--glass-navbar-brightness))') expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important') expect(baseMaterialRule).toContain('box-shadow: var(--glass-navbar-shadow) !important') @@ -529,7 +528,7 @@ describe('glass overlay material styles', () => { expect(svgFilterRule).toContain("data-glass-navbar-refraction-ready='true'") expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')") expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-high')") - expect(svgFilterRule).toContain('blur(var(--glass-navbar-blur))') + expect(svgFilterRule).not.toContain('blur(') expect(svgFilterRule).toContain('saturate(var(--glass-navbar-saturation))') expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))') expect(svgFilterRule).not.toContain('background:') diff --git a/src/styles/themes/_glass-v3.scss b/src/styles/themes/_glass-v3.scss index ace7a99b..820cd659 100644 --- a/src/styles/themes/_glass-v3.scss +++ b/src/styles/themes/_glass-v3.scss @@ -4,7 +4,7 @@ @mixin surfaces { html[data-theme='glass'] { --glass-v3-ink: 23, 27, 32; - --glass-v3-fill: clamp(0.1, calc(0.08 + var(--glass-surface-density, 0.62) * 0.12), 0.22); + --glass-v3-fill: clamp(0.12, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2), 0.32); --glass-v3-rim: clamp(0.12, calc(0.16 + var(--glass-reflection, 0.38) * 0.28), 0.4); --glass-v3-sheen: clamp(0.04, calc(0.035 + var(--glass-reflection, 0.38) * 0.18), 0.2); --glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.14); @@ -14,13 +14,7 @@ --glass-v3-navigation-inset: 8px; --glass-v3-navigation-content-gap: 16px; --glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); - --glass-v3-navigation-blur: clamp( - 0.65px, - calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px), - 1.6px - ); - --glass-v3-navigation-filter: blur(var(--glass-v3-navigation-blur)) saturate(118%) - brightness(var(--glass-transmission-brightness, 1)); + --glass-v3-navigation-filter: saturate(118%) brightness(var(--glass-transmission-brightness, 1)); --glass-v3-card-background: linear-gradient(128deg, rgba(255, 255, 255, var(--glass-v3-sheen)), transparent 38%), linear-gradient( @@ -210,6 +204,36 @@ overflow: clip; } + .layout-navbar[data-glass-panel-refraction] { + background: transparent !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: var(--glass-v3-navigation-shadow) !important; + + &::before { + position: absolute; + z-index: -1; + content: ''; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: var(--glass-v3-card-background); + box-shadow: var(--glass-v3-surface-edge); + } + } + + // 外投影与背景采样分属两层,前景导航不进入滤镜,也不让投影扩张采样边界。 + .layout-vertical-nav[data-glass-panel-refraction] { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + + .layout-vertical-nav[data-glass-panel-refraction]::before, + .layout-navbar[data-glass-panel-refraction]::before { + backdrop-filter: var(--glass-panel-filter) !important; + -webkit-backdrop-filter: var(--glass-panel-filter) !important; + } + // 标签栏由内容层另行预留;这里只补主导航内缩和下方间距,避免标签高度重复占位。 .layout-page-content { padding-block-start: calc( @@ -249,7 +273,7 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) body[data-theme='glass'] .layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) { - .layout-navbar { + .layout-navbar:not([data-glass-panel-refraction]) { --glass-navbar-live-filter: var(--glass-v3-navigation-filter); transform: none !important; @@ -299,6 +323,25 @@ } } + // 清透材质以单条原生轮廓承接真实折射,不让多组柔光在圆角处形成分离的内外弧线。 + html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) + body[data-theme='glass'] { + .v-card { + --glass-v3-surface-edge: inset 0 1px 0 var(--glass-highlight); + } + + .layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) .layout-navbar, + .layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) .layout-vertical-nav::before { + --glass-v3-surface-edge: inset 0 0 0 1px var(--glass-border-raised); + } + + @media (hover: hover) { + .v-card:hover { + --glass-v3-surface-edge: inset 0 1px 0 var(--glass-border-hover); + } + } + } + @media (prefers-reduced-transparency: reduce) { html[data-theme='glass'] { --glass-v3-navigation-filter: none !important; diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index 0e72cf45..bd6f86e0 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -53,11 +53,12 @@ html[data-theme='glass'] { --glass-control-backdrop-filter: none; --glass-control-prominent-backdrop-filter: none; --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); - --glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%); + --glass-navbar-scrolled-backdrop-filter: saturate(115%); --glass-navbar-live-filter: none; --glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter); - --glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)); - --glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px); + // 清透弹层不散射背景,靠独立吸收层区分前后两组文字,不改变内容卡片的通透度。 + --glass-overlay-surface: rgba(11, 19, 34, calc(0.58 + var(--glass-surface-density, 0.62) * 0.18)); + --glass-overlay-blur: 0px; --glass-overlay-saturate: 115%; --glass-overlay-scrim: rgba(3, 7, 18, 30%); --glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate)); @@ -67,14 +68,10 @@ html[data-theme='glass'] { --glass-control-shortcut-border: rgba(255, 255, 255, 12%); --glass-control-shortcut-color: rgba(242, 245, 250, 68%); // Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。 - --glass-chip-backdrop-filter: blur(8px) saturate(150%) brightness(var(--glass-transmission-brightness)); - --glass-chip-sheen: linear-gradient( - 135deg, - rgba(255, 255, 255, 12%), - transparent 38%, - rgba(255, 255, 255, 3%) 72%, - transparent - ); + --glass-chip-backdrop-filter: saturate(150%) brightness(var(--glass-transmission-brightness)); + --glass-chip-sheen: + linear-gradient(135deg, rgba(255, 255, 255, 12%), transparent 38%, rgba(255, 255, 255, 3%) 72%, transparent), + linear-gradient(rgba(11, 19, 34, 36%), rgba(11, 19, 34, 36%)); --glass-chip-tint-opacity: calc(0.22 + var(--glass-tint-density, 0.65) * 0.18); --glass-button-surface: rgba(255, 255, 255, 8%); --glass-button-surface-hover: rgba(255, 255, 255, 12%); @@ -187,13 +184,13 @@ html[data-theme='glass'] { ); --glass-overlay-surface: color-mix( in srgb, - rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%, + rgba(11, 19, 34, calc(0.58 + var(--glass-surface-density, 0.72) * 0.18)) 88%, rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.22)) ); - --glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px); + --glass-overlay-blur: 0px; --glass-overlay-saturate: 120%; --glass-overlay-scrim: rgba(3, 7, 18, 32%); - --glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness)); + --glass-chip-backdrop-filter: saturate(165%) brightness(var(--glass-transmission-brightness)); } // 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。 @@ -1774,11 +1771,6 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara calc(0.045 + var(--glass-surface-density, 0.62) * 0.1 + (1 - var(--glass-background-visibility, 0.58)) * 0.05), 0.28 ); - --glass-navbar-blur: clamp( - 0.35px, - calc(0.62px + var(--glass-surface-density, 0.62) * 0.8px - var(--glass-background-visibility, 0.58) * 0.25px), - 1.4px - ); --glass-navbar-brightness: var(--glass-transmission-brightness, 1); --glass-navbar-saturation: clamp( 110%, @@ -1801,8 +1793,7 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara inset 0 -1px 2px rgba(4, 10, 20, calc(0.1 + var(--glass-surface-density, 0.62) * 0.12)), 0 12px 32px rgba(3, 7, 18, calc(0.08 + var(--glass-reflection, 0.5) * 0.1 + var(--glass-surface-density, 0.62) * 0.08)); - --glass-navbar-live-filter: blur(var(--glass-navbar-blur)) saturate(var(--glass-navbar-saturation)) - brightness(var(--glass-navbar-brightness)); + --glass-navbar-live-filter: saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness)); border: 0 !important; -webkit-backdrop-filter: var(--glass-navbar-live-filter) !important; @@ -1821,8 +1812,8 @@ html[data-theme='glass'][data-glass-quality='high']:is( body[data-theme='glass'] .layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-navbar { - --glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(var(--glass-navbar-blur)) - saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness)); + --glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') saturate(var(--glass-navbar-saturation)) + brightness(var(--glass-navbar-brightness)); } html[data-theme='glass'][data-glass-quality='balanced']:is( @@ -1832,8 +1823,8 @@ html[data-theme='glass'][data-glass-quality='balanced']:is( body[data-theme='glass'] .layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-navbar { - --glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(var(--glass-navbar-blur)) - saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness)); + --glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') saturate(var(--glass-navbar-saturation)) + brightness(var(--glass-navbar-brightness)); } // 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。 @@ -1843,8 +1834,8 @@ html[data-theme='glass'][data-glass-quality='high']:is( ) body[data-theme='glass'] .layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true'] - .layout-vertical-nav:not(.overlay-nav) { - --glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') blur(1px) saturate(115%) + .layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) { + --glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') saturate(115%) brightness(var(--glass-transmission-brightness)); -webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important; @@ -1862,8 +1853,8 @@ html[data-theme='glass'][data-glass-quality='balanced']:is( ) body[data-theme='glass'] .layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true'] - .layout-vertical-nav:not(.overlay-nav) { - --glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') blur(1px) saturate(115%) + .layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) { + --glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') saturate(115%) brightness(var(--glass-transmission-brightness)); -webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important; diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts index 6133cc51..1f394476 100644 --- a/src/utils/__tests__/glassNavbarRefraction.spec.ts +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createGlassNavbarDisplacementField, + createGlassPanelBackdropField, getGlassNavbarOpticalResponse, getGlassSidebarOpticalResponse, supportsGlassNavbarLiveRefraction, @@ -81,6 +82,34 @@ describe('createGlassNavbarDisplacementField', () => { expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(128) }) + it('encodes a monotonic diffusion mask without changing the panel displacement channels', () => { + const field = createGlassNavbarDisplacementField({ width: 400, height: 240, radius: 20, surface: 'panel' }) + const weights = Array.from({ length: 24 }, (_, y) => pixelAt(field, 200, y)[1]) + expect(weights[0]).toBe(0) + expect(weights[weights.length - 1]).toBe(255) + expect(weights.every((value, index) => index === 0 || value >= weights[index - 1])).toBe(true) + expect(pixelAt(field, 0, 0)[1]).toBe(0) + expect(pixelAt(field, 200, 120)[1]).toBe(255) + }) + + it('places backdrop contours in their real coordinates without overwriting rounded gaps', () => { + const field = createGlassPanelBackdropField({ + width: 100, + height: 80, + optics: getGlassSidebarOpticalResponse({ deformation: 0, translation: 0 }), + panels: [ + { x: 10, y: 10, width: 70, height: 60, radius: 10 }, + { x: 30, y: 15, width: 50, height: 40, radius: 10 }, + ], + }) + expect(pixelAt(field, 5, 5)).toEqual([128, 255, 128, 255]) + expect(pixelAt(field, 10, 10)[1]).toBe(255) + expect(pixelAt(field, 25, 10)[1]).toBe(0) + expect(pixelAt(field, 35, 15)[1]).toBe(255) + expect(pixelAt(field, 45, 15)[1]).toBe(0) + expect(pixelAt(field, 50, 40)[1]).toBe(255) + }) + it.each([ { width: 1423, height: 64, radius: 16 }, { width: 401, height: 72, radius: 16 }, @@ -97,6 +126,10 @@ describe('createGlassNavbarDisplacementField', () => { ...[60, 252].flatMap(width => [0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })), ), + { width: 1163, height: 448, radius: 20, surface: 'panel' as const }, + { width: 358, height: 300, radius: 20, surface: 'panel' as const }, + { width: 140, height: 120, radius: 8, surface: 'panel' as const }, + { width: 140, height: 120, radius: 32, surface: 'panel' as const }, ])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => { for (const deformation of [0, 48, 100]) for (const translation of [0, 48, 100]) diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts index 835533b8..6bf94185 100644 --- a/src/utils/glassNavbarRefraction.ts +++ b/src/utils/glassNavbarRefraction.ts @@ -16,8 +16,8 @@ export interface GlassNavbarRefractionBrowserIdentity { } export interface GlassNavbarDisplacementGeometry { - /** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */ - surface?: 'navbar' | 'sidebar' + /** 顶栏保护字形高度;侧栏使用窄边透镜;大面板沿长边展开光学过渡。 */ + surface?: 'navbar' | 'sidebar' | 'panel' /** 折射表面的实际 CSS 像素高度。 */ height: number /** 最终可见外轮廓的圆角半径。 */ @@ -65,12 +65,24 @@ export function getGlassNavbarOpticalResponse( export interface GlassNavbarDisplacementField { /** 位移图的 CSS 像素高度。 */ height: number - /** 按 RGBA 顺序存储的非预乘像素通道。 */ + /** 非预乘 RGBA;R/B 为位移,panel 的 G 为中心散射权重,其余模式保持中性 G。 */ pixels: Uint8ClampedArray /** 位移图的 CSS 像素宽度。 */ width: number } +/** 稳定背板内各玻璃表面的局部坐标,按绘制顺序处理重叠区域。 */ +export interface GlassPanelBackdropGeometry { + /** 背板的 CSS 像素宽度。 */ + width: number + /** 背板的 CSS 像素高度。 */ + height: number + /** 同一背板内、均匀圆角的导航轮廓。 */ + panels: Array<{ x: number; y: number; width: number; height: number; radius: number }> + /** 背板各轮廓共用的有效光学参数。 */ + optics: GlassNavbarOpticalResponse +} + export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E' @@ -121,7 +133,7 @@ export function createGlassNavbarDisplacementField({ radius, width, surface = 'navbar', - optics = surface === 'sidebar' + optics = surface !== 'navbar' ? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural')) : DEFAULT_NAVBAR_OPTICS, }: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField { @@ -135,12 +147,14 @@ export function createGlassNavbarDisplacementField({ const maximumBand = surface === 'sidebar' ? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2) - : Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) + : surface === 'panel' + ? Math.min(48, radiusBand * 2, Math.min(pixelWidth, pixelHeight) / 2) + : Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2) const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) for (let offset = 0; offset < pixels.length; offset += 4) { pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL - pixels[offset + 1] = DISPLACEMENT_NEUTRAL_CHANNEL + pixels[offset + 1] = surface === 'panel' ? 0 : DISPLACEMENT_NEUTRAL_CHANNEL pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL pixels[offset + 3] = 255 } @@ -169,14 +183,25 @@ export function createGlassNavbarDisplacementField({ ) continue + if (surface === 'panel') { + // 清亮边缘向散射中心连续过渡;合成时两路权重互补,避免透明度凹陷形成第二圈轮廓。 + const diffusionProgress = Math.min(1, (distanceInside - outerGuard) / Math.min(4, bandWidth / 3)) + pixels[(y * pixelWidth + x) * 4 + 1] = clampChannel(255 * smoothstep(diffusionProgress)) + } + // 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。 const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64))) const verticalRamp = - surface === 'sidebar' + surface !== 'navbar' ? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64))) : smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand)))) const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX + if (surface === 'panel' && distanceInside >= bandWidth) { + pixels[(y * pixelWidth + x) * 4] = clampChannel(DISPLACEMENT_NEUTRAL_CHANNEL + translationChannel) + continue + } + if (pixelRadius === 0) { // 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。 const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard) @@ -198,7 +223,7 @@ export function createGlassNavbarDisplacementField({ const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0 const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard)) // 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。 - const verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2 + const verticalProfile = surface !== 'navbar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2 const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX const gradientX = roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) - @@ -220,9 +245,7 @@ export function createGlassNavbarDisplacementField({ return { height: pixelHeight, pixels, width: pixelWidth } } -/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */ -export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) { - const field = createGlassNavbarDisplacementField(geometry) +function encodeDisplacementField(field: GlassNavbarDisplacementField) { const canvas = document.createElement('canvas') const context = canvas.getContext('2d') @@ -238,6 +261,44 @@ export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplaceme return canvas.toDataURL('image/png') } +/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */ +export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) { + return encodeDisplacementField(createGlassNavbarDisplacementField(geometry)) +} + +/** 固定导航共用稳定壁纸输入,背板只在真实导航轮廓内进行局部折射。 */ +export function createGlassPanelBackdropField({ width, height, panels, optics }: GlassPanelBackdropGeometry) { + const pixelWidth = normalizePixelSize(width) + const pixelHeight = normalizePixelSize(height) + const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) + for (let offset = 0; offset < pixels.length; offset += 4) { + pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL + pixels[offset + 1] = 255 + pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL + pixels[offset + 3] = 255 + } + for (const panel of panels) { + const field = createGlassNavbarDisplacementField({ ...panel, optics, surface: 'panel' }) + const left = Math.round(panel.x) + const top = Math.round(panel.y) + const radius = Math.max(0, Math.min(panel.radius, field.width / 2, field.height / 2)) + for (let y = Math.max(0, -top); y < Math.min(field.height, pixelHeight - top); y += 1) { + for (let x = Math.max(0, -left); x < Math.min(field.width, pixelWidth - left); x += 1) { + if (roundedRectangleSignedDistance(x + 0.5, y + 0.5, field.width, field.height, radius) > 0) continue + const source = (y * field.width + x) * 4 + const destination = ((top + y) * pixelWidth + left + x) * 4 + pixels.set(field.pixels.subarray(source, source + 4), destination) + } + } + } + return { width: pixelWidth, height: pixelHeight, pixels } +} + +/** 稳定背板与独立表面使用同一 PNG 编码与坐标精度。 */ +export function createGlassPanelBackdropMap(geometry: GlassPanelBackdropGeometry) { + return encodeDisplacementField(createGlassPanelBackdropField(geometry)) +} + /** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */ export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) { const brands = browserIdentity.userAgentData?.brands From 35dc1ce74272b57b70b2673678f20836f5755a98 Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Mon, 7 Sep 2026 11:03:45 +0800 Subject: [PATCH 5/5] perf(glass): remove redundant chip backdrop sampling --- .../__tests__/glassOverlayMaterial.spec.ts | 15 +++++++++++++++ src/styles/themes/glass.scss | 17 ++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index e918aacf..6fbc20bf 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -150,6 +150,21 @@ describe('glass overlay material styles', () => { expect(styles).not.toMatch(/\.v-chip--variant-(?:outlined|text|plain)\s*\{/) }) + it('shares a sampling-free chip material without reducing the main frosted surfaces', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + + expect(styles.match(/--glass-chip-backdrop-filter:[^;]+;/gu)).toEqual(['--glass-chip-backdrop-filter: none;']) + expect(styles.match(/--glass-chip-sheen:/gu)).toHaveLength(2) + expect(styles).toContain('linear-gradient(rgba(11, 19, 34, 42%), rgba(11, 19, 34, 42%))') + expect(styles).toContain('--glass-chip-tint-opacity: calc(') + expect(styles).toContain('--glass-sidebar-backdrop-filter: blur(var(--glass-sidebar-diffusion-blur))') + expect(styles).toContain( + '--glass-native-surface-backdrop-filter: blur(calc(10px * var(--glass-frost-blur-scale, 1)))', + ) + expect(styles).toContain("url('#glass-navbar-live-refraction-high')") + expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')") + }) + it('keeps media source links and episode group cards on glass material tokens', () => { const mediaDetail = readFileSync(resolve(cwd(), 'src/views/discover/MediaDetailView.vue'), 'utf8') const mediaSourceRule = mediaDetail.match( diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index bd6f86e0..b649c5ce 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -67,8 +67,8 @@ html[data-theme='glass'] { --glass-control-shortcut-background: rgba(255, 255, 255, 8%); --glass-control-shortcut-border: rgba(255, 255, 255, 12%); --glass-control-shortcut-color: rgba(242, 245, 250, 68%); - // Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。 - --glass-chip-backdrop-filter: saturate(150%) brightness(var(--glass-transmission-brightness)); + // 小标签由透光底色与镜面高光承载材质,不为每个标签重复采样背景;光学预算留给主要表面。 + --glass-chip-backdrop-filter: none; --glass-chip-sheen: linear-gradient(135deg, rgba(255, 255, 255, 12%), transparent 38%, rgba(255, 255, 255, 3%) 72%, transparent), linear-gradient(rgba(11, 19, 34, 36%), rgba(11, 19, 34, 36%)); @@ -190,7 +190,6 @@ html[data-theme='glass'] { --glass-overlay-blur: 0px; --glass-overlay-saturate: 120%; --glass-overlay-scrim: rgba(3, 7, 18, 32%); - --glass-chip-backdrop-filter: saturate(165%) brightness(var(--glass-transmission-brightness)); } // 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。 @@ -261,14 +260,10 @@ html[data-theme='glass'] { --glass-overlay-blur: min(var(--glass-blur-raised), 36px); --glass-overlay-saturate: 135%; --glass-overlay-scrim: rgba(3, 7, 18, 36%); - --glass-chip-backdrop-filter: blur(14px) saturate(175%) brightness(var(--glass-transmission-brightness)); - --glass-chip-sheen: linear-gradient( - 135deg, - rgba(255, 255, 255, 14%), - transparent 36%, - rgba(255, 255, 255, 5%) 72%, - transparent - ); + // 高光负责透光感,轻量吸收底面隔开海报细节与标签文字,不重新建立背景滤镜。 + --glass-chip-sheen: + linear-gradient(135deg, rgba(255, 255, 255, 14%), transparent 36%, rgba(255, 255, 255, 5%) 72%, transparent), + linear-gradient(rgba(11, 19, 34, 42%), rgba(11, 19, 34, 42%)); --glass-blur-surface: 40px; --glass-blur: 40px; --glass-blur-raised: 60px;