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