mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16:44 +08:00
feat(glass): unify soft contours and sidebar corner refraction
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Ref } from 'vue'
|
|||||||
import {
|
import {
|
||||||
createGlassNavbarDisplacementMap,
|
createGlassNavbarDisplacementMap,
|
||||||
getGlassNavbarOpticalResponse,
|
getGlassNavbarOpticalResponse,
|
||||||
|
getGlassSidebarOpticalResponse,
|
||||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||||
} from '@/utils/glassNavbarRefraction'
|
} from '@/utils/glassNavbarRefraction'
|
||||||
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||||
@@ -214,7 +215,13 @@ function readDisplacementGeometry(surface: NavigationSurface) {
|
|||||||
const width = Math.max(1, Math.round(bounds.width))
|
const width = Math.max(1, Math.round(bounds.width))
|
||||||
|
|
||||||
const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
|
const radius = Number.isFinite(borderRadius) ? borderRadius : state.defaultGeometry.radius
|
||||||
const optics = opticalResponse.value
|
const optics =
|
||||||
|
surface === 'sidebar'
|
||||||
|
? getGlassSidebarOpticalResponse({
|
||||||
|
deformation: settings.value.glassDeformationStrength,
|
||||||
|
translation: settings.value.glassTranslationStrength,
|
||||||
|
})
|
||||||
|
: opticalResponse.value
|
||||||
return {
|
return {
|
||||||
height,
|
height,
|
||||||
radius,
|
radius,
|
||||||
@@ -241,7 +248,7 @@ async function syncDisplacementMap(surface: NavigationSurface) {
|
|||||||
try {
|
try {
|
||||||
if (state.cachedGeometry !== geometryKey) {
|
if (state.cachedGeometry !== geometryKey) {
|
||||||
if (state.failedGeometry === geometryKey) return
|
if (state.failedGeometry === geometryKey) return
|
||||||
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics })
|
const map = createGlassNavbarDisplacementMap({ height, radius, width, optics, surface })
|
||||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
|
||||||
state.failedGeometry = geometryKey
|
state.failedGeometry = geometryKey
|
||||||
invalidateDisplacementMap(surface)
|
invalidateDisplacementMap(surface)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
|
||||||
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
|
import { createGlassNavbarDisplacementMap, getGlassSidebarOpticalResponse } from '@/utils/glassNavbarRefraction'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
|
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
|
||||||
@@ -190,7 +190,13 @@ describe('GlassNavbarRefractionDefs', () => {
|
|||||||
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
expect.objectContaining({ width: 1423, height: 64, radius: 16 }),
|
||||||
)
|
)
|
||||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({ width: 260, height: 800, radius: 0 }),
|
expect.objectContaining({
|
||||||
|
width: 260,
|
||||||
|
height: 800,
|
||||||
|
radius: 0,
|
||||||
|
surface: 'sidebar',
|
||||||
|
optics: getGlassSidebarOpticalResponse({ deformation: 48, translation: 48 }),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
|
||||||
expectReadyForSidebar(260)
|
expectReadyForSidebar(260)
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { cwd } from 'node:process'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
describe('glass overlay material styles', () => {
|
describe('glass overlay material styles', () => {
|
||||||
|
it('shares the soft contour between content cards and detached navigation', () => {
|
||||||
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8')
|
||||||
|
|
||||||
|
expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow)')
|
||||||
|
expect(styles).toContain('box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow)')
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/&::before\s*\{[\s\S]*?border-radius: inherit;[\s\S]*?box-shadow: var\(--glass-v3-surface-edge\)/u,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('reserves space below detached desktop navigation and follows the compact theme radius', () => {
|
it('reserves space below detached desktop navigation and follows the compact theme radius', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8')
|
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/_glass-v3.scss'), 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--glass-v3-navigation-radius: var(--app-field-radius, 16px);
|
--glass-v3-navigation-radius: var(--app-field-radius, 16px);
|
||||||
--glass-v3-navigation-inset: 8px;
|
--glass-v3-navigation-inset: 8px;
|
||||||
--glass-v3-navigation-content-gap: 16px;
|
--glass-v3-navigation-content-gap: 16px;
|
||||||
|
--glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
--glass-v3-navigation-blur: clamp(
|
--glass-v3-navigation-blur: clamp(
|
||||||
0.65px,
|
0.65px,
|
||||||
calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px),
|
calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px),
|
||||||
@@ -45,6 +46,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme='glass'] {
|
body[data-theme='glass'] {
|
||||||
|
.v-card,
|
||||||
|
.layout-navbar,
|
||||||
|
.layout-vertical-nav::before {
|
||||||
|
// 在表面上解析反光强度,卡片 hover 的局部参数不能被根节点预先求值。
|
||||||
|
--glass-v3-surface-edge:
|
||||||
|
inset 1px 1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.3)),
|
||||||
|
inset -1px -1px 3px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.22)),
|
||||||
|
inset 0 0 5px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
// 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。
|
// 已有业务类是语义材料的适配入口;海报整图、内部图表和菜单不承载第二层卡片材料。
|
||||||
.layout-page-content
|
.layout-page-content
|
||||||
.v-card:not(
|
.v-card:not(
|
||||||
@@ -63,12 +74,7 @@
|
|||||||
.dashboard-grid-content-measure > :first-child > .v-card {
|
.dashboard-grid-content-measure > :first-child > .v-card {
|
||||||
border-radius: var(--app-theme-surface-radius, 20px) !important;
|
border-radius: var(--app-theme-surface-radius, 20px) !important;
|
||||||
background: var(--glass-v3-card-background) !important;
|
background: var(--glass-v3-card-background) !important;
|
||||||
box-shadow:
|
box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-shadow) !important;
|
||||||
inset 0 1px 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
|
||||||
inset 1px 0 0 rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.6)),
|
|
||||||
inset -1px -2px 2px rgba(0, 0, 0, 0.16),
|
|
||||||
inset 0 0 0 1px rgba(255, 255, 255, calc(var(--glass-v3-rim) * 0.25)),
|
|
||||||
var(--glass-v3-shadow) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-page-content
|
.layout-page-content
|
||||||
@@ -174,12 +180,21 @@
|
|||||||
|
|
||||||
.layout-vertical-nav {
|
.layout-vertical-nav {
|
||||||
border-radius: var(--glass-v3-navigation-radius);
|
border-radius: var(--glass-v3-navigation-radius);
|
||||||
box-shadow: var(--glass-v3-shadow) !important;
|
box-shadow: var(--glass-v3-navigation-shadow) !important;
|
||||||
block-size: calc(100% - 2 * var(--glass-v3-navigation-inset));
|
block-size: calc(100% - 2 * var(--glass-v3-navigation-inset));
|
||||||
inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important;
|
inline-size: calc(var(--glass-v3-nav-reserved-width) - var(--glass-v3-navigation-inset)) !important;
|
||||||
inset-block-start: var(--glass-v3-navigation-inset);
|
inset-block-start: var(--glass-v3-navigation-inset);
|
||||||
inset-inline-start: var(--glass-v3-navigation-inset);
|
inset-inline-start: var(--glass-v3-navigation-inset);
|
||||||
overflow: clip;
|
overflow: clip;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
// 材质与采样外壳共用圆角,内沿高光必须绕四角延续,不能绘制直边后再裁断。
|
||||||
|
border-radius: inherit;
|
||||||
|
border-inline-end: 0;
|
||||||
|
background: var(--glass-v3-card-background) !important;
|
||||||
|
// 固定导航共用柔和的掠射光,四角厚度由透镜承担,不叠加等宽硬质亮线。
|
||||||
|
box-shadow: var(--glass-v3-surface-edge) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
&.layout-vertical-nav-collapsed .layout-vertical-nav.hovered {
|
||||||
@@ -188,6 +203,7 @@
|
|||||||
|
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
border-radius: var(--glass-v3-navigation-radius) !important;
|
border-radius: var(--glass-v3-navigation-radius) !important;
|
||||||
|
box-shadow: var(--glass-v3-surface-edge), var(--glass-v3-navigation-shadow) !important;
|
||||||
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important;
|
inline-size: calc(100% - var(--glass-v3-nav-reserved-width) - 2 * var(--glass-v3-navigation-inset)) !important;
|
||||||
inset-block-start: var(--glass-v3-navigation-inset) !important;
|
inset-block-start: var(--glass-v3-navigation-inset) !important;
|
||||||
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important;
|
inset-inline-start: calc(var(--glass-v3-nav-reserved-width) + var(--glass-v3-navigation-inset)) !important;
|
||||||
@@ -237,18 +253,6 @@
|
|||||||
background: var(--glass-v3-card-background) !important;
|
background: var(--glass-v3-card-background) !important;
|
||||||
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||||
box-shadow:
|
|
||||||
inset 0 -1px 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
|
||||||
0 8px 24px rgba(0, 0, 0, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-vertical-nav::before {
|
|
||||||
border-inline-end: 0;
|
|
||||||
background: var(--glass-v3-card-background) !important;
|
|
||||||
box-shadow:
|
|
||||||
inset -1px 0 0 rgba(255, 255, 255, var(--glass-v3-rim)),
|
|
||||||
inset -3px 0 4px rgba(255, 255, 255, 0.035),
|
|
||||||
8px 0 24px rgba(0, 0, 0, 0.08) !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,16 +270,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 稳定背板已承担背景吸收,侧栏用明亮散射层呈现厚度,避免再覆盖一层深色渐变。
|
|
||||||
html[data-theme='glass'][data-glass-appearance='frosted'] body[data-theme='glass'] {
|
|
||||||
.layout-wrapper[data-shell-mode='desktop'] .layout-vertical-nav::before {
|
|
||||||
background: var(--glass-v3-card-background) !important;
|
|
||||||
box-shadow:
|
|
||||||
inset -1px 0 0 rgba(255, 255, 255, 0.32),
|
|
||||||
8px 0 24px rgba(0, 0, 0, 0.07) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。
|
// 圆角在脱离边缘的首帧采用最终值,真实inset或transform继续使用同一150ms运动节奏。
|
||||||
html[data-theme='glass']:is(
|
html[data-theme='glass']:is(
|
||||||
[data-glass-appearance='clear'],
|
[data-glass-appearance='clear'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
createGlassNavbarDisplacementField,
|
createGlassNavbarDisplacementField,
|
||||||
getGlassNavbarOpticalResponse,
|
getGlassNavbarOpticalResponse,
|
||||||
|
getGlassSidebarOpticalResponse,
|
||||||
supportsGlassNavbarLiveRefraction,
|
supportsGlassNavbarLiveRefraction,
|
||||||
} from '@/utils/glassNavbarRefraction'
|
} from '@/utils/glassNavbarRefraction'
|
||||||
|
|
||||||
@@ -45,6 +46,16 @@ describe('getGlassNavbarOpticalResponse', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getGlassSidebarOpticalResponse', () => {
|
||||||
|
it.each([0, 50, 99, 100])('keeps the same cubic sliders and equal corner axes at %s', strength => {
|
||||||
|
const parameters = Object.freeze({ deformation: strength, translation: strength })
|
||||||
|
const optics = getGlassSidebarOpticalResponse(parameters)
|
||||||
|
expect(optics.horizontalRatio).toBe(optics.verticalRatio)
|
||||||
|
expect(optics.horizontalRatio).toBeLessThanOrEqual(0.3)
|
||||||
|
expect(optics.translationPx).toBe(getGlassNavbarOpticalResponse(parameters).translationPx)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('createGlassNavbarDisplacementField', () => {
|
describe('createGlassNavbarDisplacementField', () => {
|
||||||
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
||||||
const offset = (y * field.width + x) * 4
|
const offset = (y * field.width + x) * 4
|
||||||
@@ -83,13 +94,19 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
{ width: 252, height: 846, radius: 24 },
|
{ width: 252, height: 846, radius: 24 },
|
||||||
{ width: 60, height: 846, radius: 8 },
|
{ width: 60, height: 846, radius: 8 },
|
||||||
{ width: 60, height: 846, radius: 24 },
|
{ width: 60, height: 846, radius: 24 },
|
||||||
|
...[60, 252].flatMap(width =>
|
||||||
|
[0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })),
|
||||||
|
),
|
||||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
])('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 deformation of [0, 48, 100])
|
||||||
for (const translation of [0, 48, 100])
|
for (const translation of [0, 48, 100])
|
||||||
for (const scale of [-22, -34]) {
|
for (const scale of [-22, -34]) {
|
||||||
const field = createGlassNavbarDisplacementField({
|
const field = createGlassNavbarDisplacementField({
|
||||||
...geometry,
|
...geometry,
|
||||||
optics: getGlassNavbarOpticalResponse({ deformation, translation }),
|
optics:
|
||||||
|
'surface' in geometry
|
||||||
|
? getGlassSidebarOpticalResponse({ deformation, translation })
|
||||||
|
: getGlassNavbarOpticalResponse({ deformation, translation }),
|
||||||
})
|
})
|
||||||
const source = (x: number, y: number) => {
|
const source = (x: number, y: number) => {
|
||||||
const pixel = pixelAt(field, x, y)
|
const pixel = pixelAt(field, x, y)
|
||||||
@@ -123,6 +140,31 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([8, 12, 16, 20, 24])('turns all four sidebar corners with one radial profile at radius %s', radius => {
|
||||||
|
const field = createGlassNavbarDisplacementField({
|
||||||
|
width: 252,
|
||||||
|
height: 180,
|
||||||
|
radius,
|
||||||
|
surface: 'sidebar',
|
||||||
|
optics: getGlassSidebarOpticalResponse({ deformation: 50, translation: 0 }),
|
||||||
|
})
|
||||||
|
for (let y = 0; y < radius; y += 1)
|
||||||
|
for (let x = 0; x < radius; x += 1) {
|
||||||
|
const topLeft = pixelAt(field, x, y)
|
||||||
|
const topRight = pixelAt(field, field.width - 1 - x, y)
|
||||||
|
const bottomLeft = pixelAt(field, x, field.height - 1 - y)
|
||||||
|
const bottomRight = pixelAt(field, field.width - 1 - x, field.height - 1 - y)
|
||||||
|
expect(topLeft[0] + topRight[0]).toBe(256)
|
||||||
|
expect(topLeft[2] + bottomLeft[2]).toBe(256)
|
||||||
|
expect(bottomRight[0]).toBe(topRight[0])
|
||||||
|
expect(bottomRight[2]).toBe(bottomLeft[2])
|
||||||
|
expect(pixelAt(field, y, x)[0]).toBe(topLeft[2])
|
||||||
|
}
|
||||||
|
expect(pixelAt(field, 126, 2)[2]).toBeLessThan(128)
|
||||||
|
expect(pixelAt(field, 126, 177)[2]).toBeGreaterThan(128)
|
||||||
|
expect(pixelAt(field, 126, 90)).toEqual([128, 128, 128, 255])
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a fixed rectangle optically active without substituting a rounded corner', () => {
|
it('keeps a fixed rectangle optically active without substituting a rounded corner', () => {
|
||||||
const field = createGlassNavbarDisplacementField({
|
const field = createGlassNavbarDisplacementField({
|
||||||
height: 800,
|
height: 800,
|
||||||
|
|||||||
@@ -16,26 +16,38 @@ export interface GlassNavbarRefractionBrowserIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GlassNavbarDisplacementGeometry {
|
export interface GlassNavbarDisplacementGeometry {
|
||||||
|
/** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */
|
||||||
|
surface?: 'navbar' | 'sidebar'
|
||||||
/** 折射表面的实际 CSS 像素高度。 */
|
/** 折射表面的实际 CSS 像素高度。 */
|
||||||
height: number
|
height: number
|
||||||
/** 最终可见外轮廓的圆角半径。 */
|
/** 最终可见外轮廓的圆角半径。 */
|
||||||
radius: number
|
radius: number
|
||||||
/** 折射表面的实际 CSS 像素宽度。 */
|
/** 折射表面的实际 CSS 像素宽度。 */
|
||||||
width: number
|
width: number
|
||||||
/** 由当前生效滑杆计算的顶栏光学响应;省略时采用清透自然默认值。 */
|
/** 由当前生效滑杆计算的导航光学响应;省略时采用清透自然默认值。 */
|
||||||
optics?: GlassNavbarOpticalResponse
|
optics?: GlassNavbarOpticalResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 顶栏局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
|
/** 导航局部取样预算,不包含共享 renderer 的流动、尾波与惯性。 */
|
||||||
export interface GlassNavbarOpticalResponse {
|
export interface GlassNavbarOpticalResponse {
|
||||||
/** 横向边缘峰值位移与轮廓带宽之比。 */
|
/** 横向边缘峰值位移与轮廓带宽之比。 */
|
||||||
horizontalRatio: number
|
horizontalRatio: number
|
||||||
/** 纵向峰值位移与轮廓带宽之比,严格小于横向以保护字形高度。 */
|
/** 纵向峰值位移与轮廓带宽之比;顶栏弱于横向,侧栏沿四边等向响应。 */
|
||||||
verticalRatio: number
|
verticalRatio: number
|
||||||
/** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */
|
/** 主体内容统一向右显示的 CSS 像素偏移,外轮廓平缓回零。 */
|
||||||
translationPx: number
|
translationPx: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 侧栏的阅读区由窄边带保护,圆角处两轴使用相同强度,避免转角时透镜厚度消失。 */
|
||||||
|
export function getGlassSidebarOpticalResponse(
|
||||||
|
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
||||||
|
): GlassNavbarOpticalResponse {
|
||||||
|
const navbar = getGlassNavbarOpticalResponse(parameters)
|
||||||
|
// 最大位移不超过带宽的 30%,为圆角内侧的取样回落保留单调余量。
|
||||||
|
const ratio = (navbar.horizontalRatio / 0.42) * 0.3
|
||||||
|
return { horizontalRatio: ratio, verticalRatio: ratio, translationPx: navbar.translationPx }
|
||||||
|
}
|
||||||
|
|
||||||
/** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */
|
/** 导航以低中段可读性为优先,高段保留完整位移预算;不改写共享参数或材质响应。 */
|
||||||
export function getGlassNavbarOpticalResponse(
|
export function getGlassNavbarOpticalResponse(
|
||||||
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
parameters: Pick<GlassOpticalParameters, 'deformation' | 'translation'>,
|
||||||
@@ -108,7 +120,10 @@ export function createGlassNavbarDisplacementField({
|
|||||||
height,
|
height,
|
||||||
radius,
|
radius,
|
||||||
width,
|
width,
|
||||||
optics = DEFAULT_NAVBAR_OPTICS,
|
surface = 'navbar',
|
||||||
|
optics = surface === 'sidebar'
|
||||||
|
? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||||
|
: DEFAULT_NAVBAR_OPTICS,
|
||||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||||
const pixelWidth = normalizePixelSize(width)
|
const pixelWidth = normalizePixelSize(width)
|
||||||
const pixelHeight = normalizePixelSize(height)
|
const pixelHeight = normalizePixelSize(height)
|
||||||
@@ -116,7 +131,11 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
const pixelRadius = Number.isFinite(radius) ? Math.max(0, Math.min(maxRadius, radius)) : 0
|
||||||
// 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。
|
// 直角固定表面没有圆角半径可供推导,仍使用受最短边约束的直边带;radius=0 不能被当成无折射。
|
||||||
const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX
|
const radiusBand = pixelRadius > 0 ? pixelRadius * 1.5 : REFRACTION_BAND_PX
|
||||||
const maximumBand = Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
// 侧栏边带不穿过圆角圆心,四角法线能连续转向且不会在内侧形成聚焦尖点。
|
||||||
|
const maximumBand =
|
||||||
|
surface === 'sidebar'
|
||||||
|
? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2)
|
||||||
|
: Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||||
|
|
||||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||||
@@ -152,7 +171,10 @@ export function createGlassNavbarDisplacementField({
|
|||||||
|
|
||||||
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||||
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||||
const verticalRamp = smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
const verticalRamp =
|
||||||
|
surface === 'sidebar'
|
||||||
|
? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64)))
|
||||||
|
: smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
if (pixelRadius === 0) {
|
if (pixelRadius === 0) {
|
||||||
@@ -175,7 +197,8 @@ export function createGlassNavbarDisplacementField({
|
|||||||
|
|
||||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||||
const verticalProfile = Math.sin(Math.PI * verticalProgress) ** 2
|
// 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。
|
||||||
|
const verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2
|
||||||
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
const gradientX =
|
const gradientX =
|
||||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||||
|
|||||||
Reference in New Issue
Block a user