mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 18:26:45 +08:00
feat(theme): checkpoint navbar refraction surface
This commit is contained in:
@@ -1,35 +1,77 @@
|
||||
<script lang="ts" setup>
|
||||
const displacementMapMarkup = `
|
||||
<svg viewBox="0 0 1200 80" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="x" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="rgb(24 128 0)" />
|
||||
<stop offset="8%" stop-color="rgb(128 128 0)" />
|
||||
<stop offset="92%" stop-color="rgb(128 128 0)" />
|
||||
<stop offset="100%" stop-color="rgb(232 128 0)" />
|
||||
</linearGradient>
|
||||
<linearGradient id="y" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="rgb(0 0 28)" />
|
||||
<stop offset="18%" stop-color="rgb(0 0 128)" />
|
||||
<stop offset="82%" stop-color="rgb(0 0 128)" />
|
||||
<stop offset="100%" stop-color="rgb(0 0 228)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="80" rx="20" fill="black" />
|
||||
<rect width="1200" height="80" rx="20" fill="url(#x)" />
|
||||
<rect width="1200" height="80" rx="20" fill="url(#y)" style="mix-blend-mode: screen" />
|
||||
<rect
|
||||
x="10"
|
||||
y="10"
|
||||
width="1180"
|
||||
height="60"
|
||||
rx="14"
|
||||
fill="rgb(128 128 128)"
|
||||
style="filter: blur(8px)"
|
||||
/>
|
||||
</svg>
|
||||
`
|
||||
const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacementMapMarkup)}`
|
||||
import { createGlassNavbarDisplacementMap, NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
const DEFAULT_NAVBAR_GEOMETRY = {
|
||||
height: 64,
|
||||
radius: 16,
|
||||
width: 1200,
|
||||
}
|
||||
const MAP_RESIZE_SETTLE_MS = 180
|
||||
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
|
||||
const displacementMapSize = reactive({
|
||||
height: DEFAULT_NAVBAR_GEOMETRY.height,
|
||||
width: DEFAULT_NAVBAR_GEOMETRY.width,
|
||||
})
|
||||
|
||||
let observedNavbar: HTMLElement | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function syncDisplacementMap() {
|
||||
if (!observedNavbar) return
|
||||
|
||||
const bounds = observedNavbar.getBoundingClientRect()
|
||||
const styles = getComputedStyle(observedNavbar)
|
||||
const floatingRadius = Number.parseFloat(styles.getPropertyValue('--shell-floating-navbar-radius'))
|
||||
const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
|
||||
const height = Math.max(1, Math.round(bounds.height))
|
||||
const width = Math.max(1, Math.round(bounds.width))
|
||||
|
||||
displacementMapSize.height = height
|
||||
displacementMapSize.width = width
|
||||
displacementMapUrl.value = createGlassNavbarDisplacementMap({
|
||||
height,
|
||||
radius: Number.isFinite(floatingRadius)
|
||||
? floatingRadius
|
||||
: Number.isFinite(borderRadius)
|
||||
? borderRadius
|
||||
: DEFAULT_NAVBAR_GEOMETRY.radius,
|
||||
width,
|
||||
})
|
||||
}
|
||||
|
||||
// 几何动画期间沿用上一张位移图,尺寸稳定后再重建,避免逐帧生成并上传位移纹理。
|
||||
function scheduleDisplacementMapSync() {
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = setTimeout(() => {
|
||||
resizeTimer = null
|
||||
syncDisplacementMap()
|
||||
}, MAP_RESIZE_SETTLE_MS)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
|
||||
if (!observedNavbar) return
|
||||
|
||||
syncDisplacementMap()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resizeObserver = new ResizeObserver(scheduleDisplacementMapSync)
|
||||
resizeObserver.observe(observedNavbar)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||
resizeTimer = null
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
window.removeEventListener('resize', scheduleDisplacementMapSync)
|
||||
observedNavbar = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,8 +88,8 @@ const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacement
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:width="displacementMapSize.width"
|
||||
:height="displacementMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="displacementMapUrl"
|
||||
result="map"
|
||||
@@ -66,8 +108,8 @@ const displacementMapUrl = `data:image/svg+xml,${encodeURIComponent(displacement
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
:width="displacementMapSize.width"
|
||||
:height="displacementMapSize.height"
|
||||
preserveAspectRatio="none"
|
||||
:href="displacementMapUrl"
|
||||
result="map"
|
||||
|
||||
@@ -395,6 +395,8 @@ describe('glass overlay material styles', () => {
|
||||
|
||||
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
|
||||
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
|
||||
|
||||
expect(styles).toContain("data-glass-navbar-refraction='chromium'")
|
||||
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||
@@ -402,6 +404,63 @@ describe('glass overlay material styles', () => {
|
||||
expect(styles).toMatch(
|
||||
/:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-navbar-floating-eligible\.layout-navbar-away-from-top[\s\S]*?\.layout-navbar/,
|
||||
)
|
||||
expect(styles).toContain('inline-size: auto !important')
|
||||
expect(styles).toContain('inset-block-start: 0 !important')
|
||||
expect(styles).toContain('inset-inline: 0 !important')
|
||||
expect(styles).toContain('inset-block-start: var(--shell-floating-navbar-inset) !important')
|
||||
expect(styles).toContain('inset-inline: var(--shell-floating-navbar-inset) !important')
|
||||
expect(styles).toContain('transform: none !important')
|
||||
expect(styles).toContain(
|
||||
'inset-block-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
|
||||
)
|
||||
expect(styles).toContain(
|
||||
'inset-inline-end var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
|
||||
)
|
||||
expect(styles).toContain(
|
||||
'inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing)',
|
||||
)
|
||||
const highQualityStart = styles.indexOf("html[data-theme='glass'][data-glass-quality='high']")
|
||||
const liveRefractionStart = styles.lastIndexOf(
|
||||
"html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])",
|
||||
highQualityStart,
|
||||
)
|
||||
const liveRefractionRule = styles.slice(liveRefractionStart, highQualityStart)
|
||||
|
||||
expect(liveRefractionStart).toBeGreaterThanOrEqual(0)
|
||||
expect(highQualityStart).toBeGreaterThan(liveRefractionStart)
|
||||
expect(liveRefractionRule).toContain("backdrop-filter: url('#glass-navbar-live-refraction-balanced')")
|
||||
expect(liveRefractionRule).toContain('border: 0 !important')
|
||||
expect(liveRefractionRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%)')
|
||||
expect(liveRefractionRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
|
||||
expect(styles).toContain('inline-size: min(100vw, variables.$layout-boxed-content-width)')
|
||||
expect(styles).toContain('transform: translateX(-50%) !important')
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
|
||||
)
|
||||
expect(liveRefractionRule).not.toContain('&::before')
|
||||
expect(styles).not.toContain('&::after')
|
||||
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
|
||||
expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
|
||||
expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
|
||||
expect(filterDefinitions).toContain('getBoundingClientRect()')
|
||||
expect(filterDefinitions).toContain('new ResizeObserver(scheduleDisplacementMapSync)')
|
||||
expect(filterDefinitions).toContain('in2="map"')
|
||||
expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
|
||||
expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
|
||||
expect(filterDefinitions).not.toContain('width="100%"')
|
||||
expect(filterDefinitions).not.toContain('height="100%"')
|
||||
expect(filterDefinitions).toContain('scale="-22"')
|
||||
expect(filterDefinitions).toContain('scale="-34"')
|
||||
expect(filterDefinitions).toContain('x="-8%"')
|
||||
expect(filterDefinitions).toContain('width="116%"')
|
||||
expect(filterDefinitions).toContain('x="-12%"')
|
||||
expect(filterDefinitions).toContain('width="124%"')
|
||||
expect(filterDefinitions).not.toContain('horizontal-continuity')
|
||||
expect(filterDefinitions).not.toContain('<feComposite')
|
||||
expect(filterDefinitions).not.toContain('<feGaussianBlur')
|
||||
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
|
||||
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
|
||||
expect(refractionUtilities).toContain('Math.sin(Math.PI * normalizedDistance)')
|
||||
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1652,57 +1652,70 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium 直接位移浏览器已合成的下层内容;其他引擎保留稳定的基础顶栏材质。
|
||||
// 实时折射表面在贴顶与浮动状态使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible
|
||||
.layout-navbar {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: inherit;
|
||||
-webkit-backdrop-filter: blur(1.5px) saturate(118%);
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1px 0 rgba(4, 10, 20, 0.22),
|
||||
0 12px 32px rgba(3, 7, 18, 0.16);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
inline-size: auto !important;
|
||||
inset-block-start: 0 !important;
|
||||
inset-inline: 0 !important;
|
||||
transform: none !important;
|
||||
transition:
|
||||
background-color var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
border-color var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
border-radius var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
box-shadow var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-block-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-inline-end var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing),
|
||||
inset-inline-start var(--shell-floating-navbar-motion-duration) var(--shell-floating-navbar-motion-easing) !important;
|
||||
|
||||
.navbar-content-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
// 外壳内收时,导航内容仍按视口中心对齐,避免控件位置跟随采样面的宽度变化。
|
||||
inline-size: min(100vw, variables.$layout-boxed-content-width);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 位移、材质和轮廓由同一个真实尺寸的表面完成;顶部不叠加硬高光线。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
border: 0 !important;
|
||||
-webkit-backdrop-filter: blur(1.5px) saturate(118%) !important;
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%) !important;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important;
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.2),
|
||||
inset -1px 0 0 rgba(255, 255, 255, 0.16),
|
||||
inset 0 -1px 0 rgba(4, 10, 20, 0.22),
|
||||
0 12px 32px rgba(3, 7, 18, 0.16) !important;
|
||||
inset-block-start: var(--shell-floating-navbar-inset) !important;
|
||||
inset-inline: var(--shell-floating-navbar-inset) !important;
|
||||
}
|
||||
|
||||
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-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar::before {
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
|
||||
.layout-navbar {
|
||||
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%) !important;
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-appearance='tinted']
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar::before {
|
||||
.layout-navbar {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%),
|
||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)),
|
||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15));
|
||||
linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||
import { createGlassNavbarDisplacementField, supportsGlassNavbarLiveRefraction } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
describe('createGlassNavbarDisplacementField', () => {
|
||||
function pixelAt(field: ReturnType<typeof createGlassNavbarDisplacementField>, x: number, y: number) {
|
||||
const offset = (y * field.width + x) * 4
|
||||
|
||||
return [...field.pixels.slice(offset, offset + 4)]
|
||||
}
|
||||
|
||||
it('keeps both contour boundary and interior neutral while bending only the narrow rim', () => {
|
||||
const field = createGlassNavbarDisplacementField({ height: 41, radius: 12, width: 101 })
|
||||
|
||||
expect(field.width).toBe(101)
|
||||
expect(field.height).toBe(41)
|
||||
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 20)).toEqual([128, 128, 128, 255])
|
||||
expect(pixelAt(field, 50, 5)[2]).toBeLessThan(64)
|
||||
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64)
|
||||
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192)
|
||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192)
|
||||
})
|
||||
|
||||
it('clamps invalidly small geometry to a renderable pixel surface', () => {
|
||||
const field = createGlassNavbarDisplacementField({ height: 0, radius: 20, width: -10 })
|
||||
|
||||
expect(field.width).toBe(1)
|
||||
expect(field.height).toBe(1)
|
||||
expect([...field.pixels]).toEqual([128, 128, 128, 255])
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsGlassNavbarLiveRefraction', () => {
|
||||
it('enables the verified Chromium engine path for Chrome and Edge', () => {
|
||||
|
||||
@@ -9,6 +9,124 @@ export interface GlassNavbarRefractionBrowserIdentity {
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementGeometry {
|
||||
/** 折射表面的实际 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 最终可见外轮廓的圆角半径。 */
|
||||
radius: number
|
||||
/** 折射表面的实际 CSS 像素宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementField {
|
||||
/** 位移图的 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 按 RGBA 顺序存储的非预乘像素通道。 */
|
||||
pixels: Uint8ClampedArray
|
||||
/** 位移图的 CSS 像素宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
const DISPLACEMENT_NEUTRAL_CHANNEL = 128
|
||||
const DISPLACEMENT_CHANNEL_AMPLITUDE = 127
|
||||
const OUTER_NEUTRAL_GUARD_PX = 2
|
||||
const REFRACTION_BAND_PX = 12
|
||||
const REFRACTION_PROFILE_POWER = 2
|
||||
|
||||
function normalizePixelSize(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
|
||||
}
|
||||
|
||||
function roundedRectangleSignedDistance(x: number, y: number, width: number, height: number, radius: number) {
|
||||
const offsetX = Math.abs(x - width / 2) - (width / 2 - radius)
|
||||
const offsetY = Math.abs(y - height / 2) - (height / 2 - radius)
|
||||
const outsideX = Math.max(offsetX, 0)
|
||||
const outsideY = Math.max(offsetY, 0)
|
||||
|
||||
return Math.hypot(outsideX, outsideY) + Math.min(Math.max(offsetX, offsetY), 0) - radius
|
||||
}
|
||||
|
||||
function clampChannel(value: number) {
|
||||
return Math.max(0, Math.min(255, Math.round(value)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成圆角表面的法线位移场。
|
||||
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
|
||||
*/
|
||||
export function createGlassNavbarDisplacementField({
|
||||
height,
|
||||
radius,
|
||||
width,
|
||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||
const pixelWidth = normalizePixelSize(width)
|
||||
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 bandWidth = Math.min(REFRACTION_BAND_PX, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
|
||||
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 + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
|
||||
for (let y = 0; y < pixelHeight; y += 1) {
|
||||
for (let x = 0; x < pixelWidth; x += 1) {
|
||||
const sampleX = x + 0.5
|
||||
const sampleY = y + 0.5
|
||||
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
const distanceInside = -signedDistance
|
||||
|
||||
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
|
||||
|
||||
const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard)
|
||||
const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER
|
||||
const gradientX =
|
||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
|
||||
const gradientY =
|
||||
roundedRectangleSignedDistance(sampleX, sampleY + 0.5, pixelWidth, pixelHeight, pixelRadius) -
|
||||
roundedRectangleSignedDistance(sampleX, sampleY - 0.5, pixelWidth, pixelHeight, pixelRadius)
|
||||
const gradientLength = Math.hypot(gradientX, gradientY) || 1
|
||||
const offset = (y * pixelWidth + x) * 4
|
||||
|
||||
pixels[offset] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength,
|
||||
)
|
||||
pixels[offset + 2] = clampChannel(
|
||||
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { height: pixelHeight, pixels, width: pixelWidth }
|
||||
}
|
||||
|
||||
/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
|
||||
export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
|
||||
const field = createGlassNavbarDisplacementField(geometry)
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (!context) return NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
|
||||
|
||||
canvas.width = field.width
|
||||
canvas.height = field.height
|
||||
const imageData = context.createImageData(field.width, field.height)
|
||||
|
||||
imageData.data.set(field.pixels)
|
||||
context.putImageData(imageData, 0, 0)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */
|
||||
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
|
||||
const brands = browserIdentity.userAgentData?.brands
|
||||
|
||||
Reference in New Issue
Block a user