diff --git a/src/@layouts/components/VerticalNavLayout.vue b/src/@layouts/components/VerticalNavLayout.vue index 096eadc7..4cad8e05 100644 --- a/src/@layouts/components/VerticalNavLayout.vue +++ b/src/@layouts/components/VerticalNavLayout.vue @@ -211,6 +211,7 @@ export default defineComponent({ ? 'theme-qualified' : 'connected', 'data-shell-scroll-direction': shellScroll.direction.value, + 'data-glass-navigation-refraction': navbarRefractionMode, 'data-glass-navbar-refraction': navbarRefractionMode, style: { '--layout-footer-dock-height': `${footerDockHeight.value ?? 0}px`, diff --git a/src/components/theme/GlassNavbarRefractionDefs.vue b/src/components/theme/GlassNavbarRefractionDefs.vue index 752f6203..b1c9c63a 100644 --- a/src/components/theme/GlassNavbarRefractionDefs.vue +++ b/src/components/theme/GlassNavbarRefractionDefs.vue @@ -1,4 +1,5 @@ @@ -285,10 +426,10 @@ onBeforeUnmount(() => { @@ -305,10 +446,50 @@ onBeforeUnmount(() => { + + + + + + + + + + diff --git a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts index b10092ad..6dd63e16 100644 --- a/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts +++ b/src/components/theme/__tests__/GlassNavbarRefractionDefs.spec.ts @@ -18,7 +18,9 @@ describe('GlassNavbarRefractionDefs', () => { let resize: ResizeObserverCallback | undefined let wrapper: ReturnType | undefined let width: number + let sidebarWidth: number let radius: number + let sidebar: HTMLElement | undefined let transparencyReduced: boolean let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }> @@ -30,32 +32,41 @@ describe('GlassNavbarRefractionDefs', () => { vi.clearAllMocks() effectiveSettings.value = { glassDeformationStrength: 48, glassTranslationStrength: 48 } width = 1423 + sidebarWidth = 260 radius = 16 transparencyReduced = false transparencyChange = undefined decodePending = [] + sidebar = undefined shell = document.createElement('div') - shell.className = 'layout-wrapper layout-navbar-floating-eligible layout-navbar-away-from-top' + shell.className = + 'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top' shell.dataset.glassNavbarRefraction = 'chromium' shell.innerHTML = '
' document.body.append(shell) navbar = shell.querySelector('.layout-navbar') as HTMLElement Object.assign(document.documentElement.dataset, { theme: 'glass', glassAppearance: 'clear', glassQuality: 'high' }) - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ - x: 16, - y: 16, - left: 16, - top: 16, - width, - height: 64, - right: width + 16, - bottom: 80, - toJSON: () => ({}), - })) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const isSidebar = this.classList.contains('layout-vertical-nav') + const elementWidth = isSidebar ? sidebarWidth : width + const elementHeight = isSidebar ? 800 : 64 + + return { + x: 16, + y: 16, + left: 16, + top: 16, + width: elementWidth, + height: elementHeight, + right: elementWidth + 16, + bottom: elementHeight + 16, + toJSON: () => ({}), + } + }) vi.spyOn(window, 'getComputedStyle').mockImplementation( - () => + element => ({ - borderStartStartRadius: `${radius}px`, + borderStartStartRadius: element.classList.contains('layout-vertical-nav') ? '0px' : `${radius}px`, getPropertyValue: () => '1rem', }) as unknown as CSSStyleDeclaration, ) @@ -112,6 +123,12 @@ describe('GlassNavbarRefractionDefs', () => { expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth)) } + function expectReadyForSidebar(expectedWidth: number) { + expect(shell.dataset.glassSidebarRefractionReady).toBe('true') + expect(wrapper?.findAll('feImage')[2].attributes('width')).toBe(String(expectedWidth)) + expect(wrapper?.findAll('feImage')[2].attributes('height')).toBe('800') + } + function completePendingDecode() { for (const pending of decodePending.splice(0)) pending.resolve() } @@ -122,6 +139,13 @@ describe('GlassNavbarRefractionDefs', () => { navbar.dispatchEvent(event) } + function mountWithSidebar(overlay = false) { + sidebar = document.createElement('aside') + sidebar.className = `layout-vertical-nav${overlay ? ' overlay-nav' : ''}` + shell.prepend(sidebar) + wrapper = mount(GlassNavbarRefractionDefs) + } + it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => { wrapper = mount(GlassNavbarRefractionDefs) expect(shell.dataset.glassNavbarRefractionReady).toBe('false') @@ -132,6 +156,87 @@ describe('GlassNavbarRefractionDefs', () => { expectReadyForWidth(1423) }) + it('keeps independent geometry caches when switching between horizontal and vertical navigation', async () => { + mountWithSidebar() + await settle() + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1) + expectReadyForWidth(1423) + expect(shell.dataset.glassSidebarRefractionReady).toBe('false') + shell.className = 'layout-wrapper' + await flushPromises() + await settle() + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2) + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith( + expect.objectContaining({ width: 1423, height: 64, radius: 16 }), + ) + expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( + expect.objectContaining({ width: 260, height: 800, radius: 0 }), + ) + expect(shell.dataset.glassNavbarRefractionReady).toBe('false') + expectReadyForSidebar(260) + + shell.className = + 'layout-wrapper layout-horizontal-nav-active layout-navbar-floating-eligible layout-navbar-away-from-top' + await flushPromises() + await settle() + expectReadyForWidth(1423) + expect(shell.dataset.glassSidebarRefractionReady).toBe('false') + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2) + }) + + it('rebuilds only the sidebar map when its collapsed width changes', async () => { + shell.className = 'layout-wrapper' + mountWithSidebar() + await settle() + const initialCallCount = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length + + sidebarWidth = 80 + resize?.([], {} as ResizeObserver) + await settle() + + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(initialCallCount + 1) + expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith( + expect.objectContaining({ width: 80, height: 800, radius: 0 }), + ) + expect(shell.dataset.glassNavbarRefractionReady).toBe('false') + expectReadyForSidebar(80) + }) + + it('does not generate a live map for a mobile Drawer surface', async () => { + shell.className = 'layout-wrapper layout-overlay-nav' + mountWithSidebar(true) + await settle() + + expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled() + expect(shell.dataset.glassNavbarRefractionReady).toBe('false') + expect(shell.dataset.glassSidebarRefractionReady).toBe('false') + }) + + it('activates the existing sidebar when a Drawer viewport returns to desktop', async () => { + shell.className = 'layout-wrapper layout-overlay-nav' + mountWithSidebar(true) + await settle() + shell.classList.remove('layout-overlay-nav') + sidebar?.classList.remove('overlay-nav') + await flushPromises() + await settle() + expectReadyForSidebar(260) + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1) + }) + + it('does not strand a sidebar update when an unrelated fixed navbar transition ends', async () => { + shell.className = 'layout-wrapper' + mountWithSidebar() + await settle() + dispatchTransition('transitionrun', 'height') + effectiveSettings.value = { glassDeformationStrength: 99, glassTranslationStrength: 99 } + dispatchTransition('transitionend', 'height') + completePendingDecode() + await flushPromises() + expectReadyForSidebar(260) + expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(2) + }) + it('keeps unchanged geometry ready but disables old sampling when the size changes', async () => { wrapper = mount(GlassNavbarRefractionDefs) await settle() diff --git a/src/styles/__tests__/glassOverlayMaterial.spec.ts b/src/styles/__tests__/glassOverlayMaterial.spec.ts index b0f59dbe..8cf92d58 100644 --- a/src/styles/__tests__/glassOverlayMaterial.spec.ts +++ b/src/styles/__tests__/glassOverlayMaterial.spec.ts @@ -189,7 +189,7 @@ describe('glass overlay material styles', () => { expect(styles).toContain('--glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px))') expect(styles).toMatch( - /&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/, + /&\[data-glass-appearance='frosted'\]\[data-glass-quality='css'\]\s+body\[data-theme='glass'\][\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-vertical-nav::before,[\s\S]*?\.layout-wrapper\.layout-fixed-shell-backplate-active \.layout-navbar,[\s\S]*?backdrop-filter:\s*none\s*!important;/, ) expect(styles).toMatch( /\[data-glass-appearance='frosted'\]\[data-glass-quality='balanced'\]\s*\{[\s\S]*?--glass-fixed-shell-backplate-filter:\s*var\(--glass-native-surface-backdrop-filter\);/, @@ -226,6 +226,42 @@ describe('glass overlay material styles', () => { expect(overlayBackplateRule).toMatch(/transition:\s*clip-path 0\.25s ease-in-out/u) }) + it('keeps desktop sidebar refraction isolated from the attached navbar and mobile Drawer', () => { + const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') + const defs = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8') + const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8') + + expect(styles).toContain('--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter)') + expect(styles).toContain('--glass-sidebar-diffusion-blur: clamp(') + expect(styles).toContain('--glass-sidebar-absorption-start: clamp(') + expect(styles).toContain('--glass-sidebar-absorption-end: clamp(') + expect(styles).toContain('--glass-sidebar-edge-opacity: clamp(') + expect(styles).toMatch( + /\.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'\)/, + ) + 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'\)/, + ) + 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\)/, + ) + expect(defs).toContain('id="glass-sidebar-live-refraction-balanced"') + expect(defs).toContain('id="glass-sidebar-live-refraction-high"') + expect(styles).not.toContain("url('#glass-sidebar-live-refraction-high') var(--glass-fixed-shell-backdrop-filter)") + expect(styles).toContain('--glass-sidebar-live-filter: none !important') + expect(styles).toContain('--glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter)') + expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: none') + expect(defs).toContain("readyAttribute: 'data-glass-sidebar-refraction-ready'") + expect(layout).toContain("'data-glass-navigation-refraction': navbarRefractionMode") + expect(layout).toContain("'data-glass-navbar-refraction': navbarRefractionMode") + expect(styles).not.toContain( + ".layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']\n .layout-navbar", + ) + }) + it('limits detached navbar geometry to eligible Transparent and Glass horizontal shells', () => { const layout = readFileSync(resolve(cwd(), 'src/@layouts/components/VerticalNavLayout.vue'), 'utf8') diff --git a/src/styles/themes/glass.scss b/src/styles/themes/glass.scss index 7271cae8..ba4f7042 100644 --- a/src/styles/themes/glass.scss +++ b/src/styles/themes/glass.scss @@ -54,6 +54,7 @@ html[data-theme='glass'] { --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); --glass-navbar-scrolled-backdrop-filter: blur(3px) 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-saturate: 115%; @@ -228,6 +229,35 @@ html[data-theme='glass'] { brightness(var(--glass-transmission-brightness)); --glass-fixed-shell-backplate-filter: blur(min(var(--glass-blur-raised), 60px)) saturate(var(--glass-saturate)) brightness(var(--glass-transmission-brightness)); + // 固定大表面用受控扩散与吸收表达厚度,避免直接沿用卡片的高频玻璃参数。 + --glass-sidebar-diffusion-blur: clamp( + 14px, + calc(6px + var(--glass-surface-density, 0.86) * 16px + (1 - var(--glass-background-visibility, 0.58)) * 6px), + 28px + ); + --glass-sidebar-diffusion-saturation: clamp( + 126%, + calc(112% + var(--glass-background-visibility, 0.58) * 30% + var(--glass-surface-density, 0.86) * 8%), + 154% + ); + --glass-sidebar-absorption-start: clamp( + 0.18, + calc(0.08 + var(--glass-surface-density, 0.86) * 0.2 + (1 - var(--glass-background-visibility, 0.58)) * 0.12), + 0.42 + ); + --glass-sidebar-absorption-end: clamp( + 0.28, + calc(var(--glass-sidebar-absorption-start) + 0.12 + (1 - var(--glass-background-visibility, 0.58)) * 0.08), + 0.54 + ); + --glass-sidebar-edge-opacity: clamp( + 0.12, + calc(0.08 + var(--glass-reflection, 0.5) * 0.22 + (1 - var(--glass-background-visibility, 0.58)) * 0.06), + 0.4 + ); + --glass-sidebar-backdrop-filter: blur(var(--glass-sidebar-diffusion-blur)) + saturate(var(--glass-sidebar-diffusion-saturation)) brightness(var(--glass-transmission-brightness)); + --glass-sidebar-live-filter: var(--glass-sidebar-backdrop-filter); --glass-control-prominent-backdrop-filter: blur(24px) saturate(150%); --glass-overlay-surface: rgba(var(--v-theme-background), calc(0.24 + var(--glass-surface-density, 0.86) * 0.12)); --glass-overlay-blur: min(var(--glass-blur-raised), 36px); @@ -490,8 +520,8 @@ html[data-theme='glass'] { position: absolute; z-index: -1; border-inline-end: 1px solid var(--glass-border-raised); - -webkit-backdrop-filter: var(--glass-fixed-shell-backdrop-filter); - backdrop-filter: var(--glass-fixed-shell-backdrop-filter); + -webkit-backdrop-filter: var(--glass-sidebar-live-filter); + backdrop-filter: var(--glass-sidebar-live-filter); background-color: var(--glass-surface-raised); background-image: var(--glass-sheen); box-shadow: var(--glass-shadow-raised); @@ -501,6 +531,59 @@ html[data-theme='glass'] { } } + &[data-glass-appearance='frosted'] { + .layout-wrapper[data-shell-mode='desktop'] { + --glass-navbar-backdrop-filter: var(--glass-sidebar-backdrop-filter); + } + + // Frosted 的大表面先扩散再吸收背景,边缘高光只保留在真实 DOM 背板的外轮廓。 + .layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before { + border-inline-end: 0; + background-color: transparent; + background-image: + var(--glass-sheen), + linear-gradient( + 90deg, + rgba(7, 14, 25, var(--glass-sidebar-absorption-start)), + rgba(7, 14, 25, var(--glass-sidebar-absorption-end)) + ); + box-shadow: + inset -1px 0 0 rgba(255, 255, 255, var(--glass-sidebar-edge-opacity)), + 0 8px 24px rgba(3, 7, 18, 0.12); + } + + .layout-wrapper[data-shell-mode='desktop'] > .glass-fixed-shell-backplate--main { + // 固定顶栏与侧栏共用同一 L 形稳定背板,扩散参数必须作用于真实承载层。 + --glass-fixed-shell-backplate-filter: var(--glass-sidebar-backdrop-filter); + } + + .layout-wrapper[data-shell-mode='desktop'].layout-fixed-shell-backplate-active { + --glass-navbar-scrolled-backdrop-filter: none; + + .layout-navbar, + .layout-vertical-nav::before { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + } + } + + .layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-vertical-nav::before, + .layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar { + -webkit-backdrop-filter: var(--glass-sidebar-backdrop-filter) !important; + backdrop-filter: var(--glass-sidebar-backdrop-filter) !important; + } + + .layout-wrapper[data-shell-mode='desktop']:not(.layout-fixed-shell-backplate-active) .layout-navbar { + // 原生回退没有壁纸背板的吸收层,独立保证繁忙页面下的文字对比。 + background: + var(--glass-sheen), + linear-gradient( + rgba(7, 14, 25, var(--glass-sidebar-absorption-start)), + rgba(7, 14, 25, var(--glass-sidebar-absorption-end)) + ) !important; + } + } + // Chromium 磨砂 fixed 层直接渲染稳定壁纸背板,固定功能层不得读取随页面重绘的 document backdrop。 &[data-glass-appearance='frosted'][data-glass-quality='css'] body[data-theme='glass'] { .layout-wrapper.layout-fixed-shell-backplate-active .layout-vertical-nav::before, @@ -1764,6 +1847,45 @@ html[data-theme='glass'][data-glass-quality='balanced']:is( saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness)); } +// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。 +html[data-theme='glass'][data-glass-quality='high']:is( + [data-glass-appearance='clear'], + [data-glass-appearance='tinted'] + ) + 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%) + brightness(var(--glass-transmission-brightness)); + + -webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important; + backdrop-filter: var(--glass-sidebar-live-filter) !important; + + &::before { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + } +} + +html[data-theme='glass'][data-glass-quality='balanced']:is( + [data-glass-appearance='clear'], + [data-glass-appearance='tinted'] + ) + 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%) + brightness(var(--glass-transmission-brightness)); + + -webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important; + backdrop-filter: var(--glass-sidebar-live-filter) !important; + + &::before { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + } +} + html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'] .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top @@ -1778,6 +1900,10 @@ html[data-theme='glass'][data-glass-appearance='tinted'] } @media (prefers-reduced-transparency: reduce) { + html[data-theme='glass'] body[data-theme='glass'] .layout-vertical-nav { + --glass-sidebar-live-filter: none !important; + } + html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) body[data-theme='glass'] .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top diff --git a/src/utils/__tests__/glassNavbarRefraction.spec.ts b/src/utils/__tests__/glassNavbarRefraction.spec.ts index 1c0f36d9..61e56b11 100644 --- a/src/utils/__tests__/glassNavbarRefraction.spec.ts +++ b/src/utils/__tests__/glassNavbarRefraction.spec.ts @@ -75,6 +75,7 @@ describe('createGlassNavbarDisplacementField', () => { { width: 401, height: 72, radius: 16 }, { width: 127, height: 64, radius: 8 }, { width: 127, height: 64, radius: 32 }, + { width: 260, height: 800, radius: 0 }, ])('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]) @@ -115,6 +116,21 @@ describe('createGlassNavbarDisplacementField', () => { } }) + it('keeps a fixed rectangle optically active without substituting a rounded corner', () => { + const field = createGlassNavbarDisplacementField({ + height: 800, + radius: 0, + width: 260, + optics: getGlassNavbarOpticalResponse({ deformation: 100, translation: 0 }), + }) + + expect(pixelAt(field, 130, 0)).toEqual([128, 128, 128, 255]) + expect(pixelAt(field, 130, 8)[2]).toBeLessThan(128) + expect(pixelAt(field, 8, 400)[0]).toBeLessThan(128) + expect(pixelAt(field, 8, 8)[0]).toBeLessThan(128) + expect(pixelAt(field, 8, 8)[2]).toBeLessThan(128) + }) + it('clamps invalidly small geometry to a renderable pixel surface', () => { const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 }) diff --git a/src/utils/glassNavbarRefraction.ts b/src/utils/glassNavbarRefraction.ts index 0d13de55..ec2ff56e 100644 --- a/src/utils/glassNavbarRefraction.ts +++ b/src/utils/glassNavbarRefraction.ts @@ -93,8 +93,10 @@ function smoothstep(value: number) { /** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */ function refractionProfile(depth: number, band: number, guard: number) { + if (depth <= guard || depth >= band) return 0 + const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO) - if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard)) + if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard || 1)) return 1 - smoothstep((depth - peakDepth) / (band - peakDepth)) } @@ -112,7 +114,9 @@ export function createGlassNavbarDisplacementField({ const pixelHeight = normalizePixelSize(height) const maxRadius = Math.min(pixelWidth, pixelHeight) / 2 const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0 - const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2) + // 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;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 pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4) for (let offset = 0; offset < pixels.length; offset += 4) { @@ -133,7 +137,8 @@ export function createGlassNavbarDisplacementField({ const edgeY = Math.min(sampleY, pixelHeight - sampleY) const straightWeight = smoothstep(Math.min(1, Math.abs(edgeX - edgeY) / (maximumBand * 2 || 1))) const cornerBand = Math.min(maximumBand, pixelRadius) - const bandWidth = cornerBand + (maximumBand - cornerBand) * straightWeight + // 矩形角点没有圆弧法线;固定带宽交给四条直边的轴向剖面处理,避免角点成为采样断点。 + const bandWidth = pixelRadius === 0 ? maximumBand : cornerBand + (maximumBand - cornerBand) * straightWeight const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4) const channelAmplitude = (bandWidth * optics.horizontalRatio * 255) / HIGH_REFRACTION_SCALE_PX @@ -145,6 +150,29 @@ export function createGlassNavbarDisplacementField({ ) continue + // 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。 + 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 translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX + + if (pixelRadius === 0) { + // 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。 + const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard) + const rightProfile = refractionProfile(pixelWidth - sampleX, maximumBand, outerGuard) + const topProfile = refractionProfile(sampleY, maximumBand, outerGuard) + const bottomProfile = refractionProfile(pixelHeight - sampleY, maximumBand, outerGuard) + const verticalAmplitude = (maximumBand * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX + const offset = (y * pixelWidth + x) * 4 + + pixels[offset] = clampChannel( + DISPLACEMENT_NEUTRAL_CHANNEL + channelAmplitude * (rightProfile - leftProfile) + translationChannel, + ) + pixels[offset + 2] = clampChannel( + DISPLACEMENT_NEUTRAL_CHANNEL + verticalAmplitude * (bottomProfile - topProfile), + ) + continue + } + 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 @@ -157,11 +185,6 @@ export function createGlassNavbarDisplacementField({ roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius) const gradientLength = Math.hypot(gradientX, gradientY) || 1 const offset = (y * pixelWidth + x) * 4 - // 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。 - 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 translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX - pixels[offset] = clampChannel( DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength + translationChannel, )