feat(glass): anchor expressive navbar refraction POC

This commit is contained in:
InfinityPacer
2026-09-06 01:27:19 +08:00
parent e84845047f
commit dbc75f9165
6 changed files with 622 additions and 75 deletions
@@ -6,7 +6,20 @@ const DEFAULT_NAVBAR_GEOMETRY = {
radius: 16, radius: 16,
width: 1200, width: 1200,
} }
const MAP_RESIZE_SETTLE_MS = 180 const MAP_RESIZE_SETTLE_MS = 60
const OBSERVED_SIZE_STYLE_PROPERTIES = [
'--shell-floating-navbar-radius',
'--shell-floating-navbar-inset',
'--layout-navbar-block-size',
'--layout-navbar-safe-area-top',
'--navbar-tab-height',
'border-radius',
'border-start-start-radius',
'width',
'height',
'inline-size',
'block-size',
]
const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) const displacementMapUrl = ref(NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP)
const displacementMapSize = reactive({ const displacementMapSize = reactive({
height: DEFAULT_NAVBAR_GEOMETRY.height, height: DEFAULT_NAVBAR_GEOMETRY.height,
@@ -14,46 +27,167 @@ const displacementMapSize = reactive({
}) })
let observedNavbar: HTMLElement | null = null let observedNavbar: HTMLElement | null = null
let observedShell: HTMLElement | null = null
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
let stateObserver: MutationObserver | null = null
let resizeTimer: ReturnType<typeof setTimeout> | null = null let resizeTimer: ReturnType<typeof setTimeout> | null = null
let transparencyQuery: MediaQueryList | null = null
let mapRevision = 0
let cachedGeometry = ''
let cachedMap = NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP
let failedGeometry = ''
let lastObservedGeometry = ''
const geometryTransitions = new Set<string>()
function syncDisplacementMap() { /** CSS 档、非水平浮动态与无障碍回退不生成或启用位移图。 */
if (!observedNavbar) return function isRefractionActive() {
const { theme, glassAppearance, glassQuality } = document.documentElement.dataset
return (
theme === 'glass' &&
(glassAppearance === 'clear' || glassAppearance === 'tinted') &&
(glassQuality === 'balanced' || glassQuality === 'high') &&
observedShell?.classList.contains('layout-navbar-floating-eligible') &&
observedShell.classList.contains('layout-navbar-away-from-top') &&
!transparencyQuery?.matches
)
}
/** 几何或状态变化后立即撤销旧 map,避免在另一个尺寸中采样。 */
function invalidateDisplacementMap() {
mapRevision += 1
observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'false')
}
function getInlineStyleValue(styleText: string | null, property: string) {
const declarations = document.createElement('div').style
declarations.cssText = styleText ?? ''
return declarations.getPropertyValue(property).trim()
}
/** 滚动缩放变量不改变真实采样几何,只有尺寸声明变化才撤销当前 map。 */
function hasObservedSizeStyleChange(record: MutationRecord) {
if (record.attributeName !== 'style') return true
const target = record.target as Element
const currentStyle = target.getAttribute('style')
return OBSERVED_SIZE_STYLE_PROPERTIES.some(
property => getInlineStyleValue(record.oldValue, property) !== getInlineStyleValue(currentStyle, property),
)
}
function handleStateMutations(records: MutationRecord[]) {
if (records.some(hasObservedSizeStyleChange)) scheduleDisplacementMapSync()
}
/** map 与 feImage 尺寸同批更新;解码失败或过期结果继续使用 CSS 材质。 */
async function syncDisplacementMap() {
if (!observedNavbar || !isRefractionActive() || geometryTransitions.size > 0) return
const revision = ++mapRevision
const bounds = observedNavbar.getBoundingClientRect() const bounds = observedNavbar.getBoundingClientRect()
const styles = getComputedStyle(observedNavbar) const styles = getComputedStyle(observedNavbar)
const floatingRadius = Number.parseFloat(styles.getPropertyValue('--shell-floating-navbar-radius')) // 自定义属性可能保留 rem;只有计算后的圆角与位移图使用同一 CSS 像素坐标。
const borderRadius = Number.parseFloat(styles.borderStartStartRadius) const borderRadius = Number.parseFloat(styles.borderStartStartRadius)
const height = Math.max(1, Math.round(bounds.height)) const height = Math.max(1, Math.round(bounds.height))
const width = Math.max(1, Math.round(bounds.width)) const width = Math.max(1, Math.round(bounds.width))
const radius = Number.isFinite(borderRadius) ? borderRadius : DEFAULT_NAVBAR_GEOMETRY.radius
const geometryKey = `${width}:${height}:${radius}`
if (lastObservedGeometry !== geometryKey) {
lastObservedGeometry = geometryKey
failedGeometry = ''
}
try {
if (cachedGeometry !== geometryKey) {
if (failedGeometry === geometryKey) return
const map = createGlassNavbarDisplacementMap({ height, radius, width })
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) {
failedGeometry = geometryKey
invalidateDisplacementMap()
return
}
const decoded = new Image()
decoded.src = map
await decoded.decode()
if (revision !== mapRevision || !isRefractionActive()) return
cachedGeometry = geometryKey
cachedMap = map
failedGeometry = ''
}
displacementMapSize.height = height displacementMapSize.height = height
displacementMapSize.width = width displacementMapSize.width = width
displacementMapUrl.value = createGlassNavbarDisplacementMap({ displacementMapUrl.value = cachedMap
height, await nextTick()
radius: Number.isFinite(floatingRadius) if (revision === mapRevision && isRefractionActive()) {
? floatingRadius observedShell?.setAttribute('data-glass-navbar-refraction-ready', 'true')
: Number.isFinite(borderRadius) }
? borderRadius } catch {
: DEFAULT_NAVBAR_GEOMETRY.radius, // 位移是增强能力;图片解码失败不阻断导航和原生玻璃表面。
width, if (revision === mapRevision) {
}) failedGeometry = geometryKey
invalidateDisplacementMap()
}
}
} }
// 几何动画期间沿用上一张位移图,尺寸稳定后重建,避免逐帧生成并上传位移纹理 // 动画期间使用同族 CSS 材质;尺寸稳定后重建一次,不逐帧生成或拉伸旧图
function scheduleDisplacementMapSync() { function scheduleDisplacementMapSync() {
invalidateDisplacementMap()
if (resizeTimer !== null) clearTimeout(resizeTimer) if (resizeTimer !== null) clearTimeout(resizeTimer)
if (!isRefractionActive()) return
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
resizeTimer = null resizeTimer = null
syncDisplacementMap() void syncDisplacementMap()
}, MAP_RESIZE_SETTLE_MS) }, MAP_RESIZE_SETTLE_MS)
} }
function handleGeometryTransition(event: TransitionEvent) {
if (
event.target !== observedNavbar ||
!/^(inset|top|left|right|width|height|inline-size|block-size|border.*radius)/u.test(event.propertyName)
)
return
if (event.type === 'transitionrun') {
geometryTransitions.add(event.propertyName)
invalidateDisplacementMap()
} else {
geometryTransitions.delete(event.propertyName)
if (geometryTransitions.size === 0) scheduleDisplacementMapSync()
}
}
onMounted(() => { onMounted(() => {
observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar') observedNavbar = document.querySelector('.layout-wrapper[data-glass-navbar-refraction="chromium"] .layout-navbar')
if (!observedNavbar) return if (!observedNavbar) return
observedShell = observedNavbar.closest('.layout-wrapper')
transparencyQuery = window.matchMedia('(prefers-reduced-transparency: reduce)')
transparencyQuery.addEventListener('change', scheduleDisplacementMapSync)
stateObserver = new MutationObserver(handleStateMutations)
stateObserver.observe(document.documentElement, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style', 'data-theme', 'data-glass-appearance', 'data-glass-quality'],
})
if (observedShell) {
stateObserver.observe(observedShell, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style'],
})
}
stateObserver.observe(observedNavbar, {
attributes: true,
attributeOldValue: true,
attributeFilter: ['class', 'style'],
})
observedNavbar.addEventListener('transitionrun', handleGeometryTransition)
observedNavbar.addEventListener('transitionend', handleGeometryTransition)
observedNavbar.addEventListener('transitioncancel', handleGeometryTransition)
syncDisplacementMap() scheduleDisplacementMapSync()
if (typeof ResizeObserver === 'undefined') { if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true }) window.addEventListener('resize', scheduleDisplacementMapSync, { passive: true })
@@ -65,12 +199,23 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
invalidateDisplacementMap()
geometryTransitions.clear()
if (resizeTimer !== null) clearTimeout(resizeTimer) if (resizeTimer !== null) clearTimeout(resizeTimer)
resizeTimer = null resizeTimer = null
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
stateObserver?.disconnect()
stateObserver = null
transparencyQuery?.removeEventListener('change', scheduleDisplacementMapSync)
transparencyQuery = null
observedNavbar?.removeEventListener('transitionrun', handleGeometryTransition)
observedNavbar?.removeEventListener('transitionend', handleGeometryTransition)
observedNavbar?.removeEventListener('transitioncancel', handleGeometryTransition)
observedShell?.removeAttribute('data-glass-navbar-refraction-ready')
window.removeEventListener('resize', scheduleDisplacementMapSync) window.removeEventListener('resize', scheduleDisplacementMapSync)
observedNavbar = null observedNavbar = null
observedShell = null
}) })
</script> </script>
@@ -79,10 +224,10 @@ onBeforeUnmount(() => {
<defs> <defs>
<filter <filter
id="glass-navbar-live-refraction-balanced" id="glass-navbar-live-refraction-balanced"
x="-8%" x="0%"
y="-80%" y="0%"
width="116%" width="100%"
height="260%" height="100%"
color-interpolation-filters="sRGB" color-interpolation-filters="sRGB"
> >
<feImage <feImage
@@ -99,10 +244,10 @@ onBeforeUnmount(() => {
<filter <filter
id="glass-navbar-live-refraction-high" id="glass-navbar-live-refraction-high"
x="-12%" x="0%"
y="-100%" y="0%"
width="124%" width="100%"
height="300%" height="100%"
color-interpolation-filters="sRGB" color-interpolation-filters="sRGB"
> >
<feImage <feImage
@@ -0,0 +1,272 @@
import { mount, flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import GlassNavbarRefractionDefs from '../GlassNavbarRefractionDefs.vue'
import { createGlassNavbarDisplacementMap } from '@/utils/glassNavbarRefraction'
vi.mock('@/utils/glassNavbarRefraction', () => ({
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,test'),
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP: 'neutral',
}))
describe('GlassNavbarRefractionDefs', () => {
let shell: HTMLDivElement
let navbar: HTMLElement
let resize: ResizeObserverCallback | undefined
let wrapper: ReturnType<typeof mount> | undefined
let width: number
let radius: number
let transparencyReduced: boolean
let transparencyChange: ((event: MediaQueryListEvent) => void) | undefined
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
const disconnect = vi.fn()
const observe = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
width = 1423
radius = 16
transparencyReduced = false
transparencyChange = undefined
decodePending = []
shell = document.createElement('div')
shell.className = 'layout-wrapper layout-navbar-floating-eligible layout-navbar-away-from-top'
shell.dataset.glassNavbarRefraction = 'chromium'
shell.innerHTML = '<header class="layout-navbar"></header>'
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(window, 'getComputedStyle').mockImplementation(
() =>
({
borderStartStartRadius: `${radius}px`,
getPropertyValue: () => '1rem',
}) as unknown as CSSStyleDeclaration,
)
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: ResizeObserverCallback) {
resize = callback
}
observe = observe
disconnect = disconnect
},
)
vi.stubGlobal('matchMedia', () => ({
get matches() {
return transparencyReduced
},
addEventListener: vi.fn((_event: string, listener: (event: MediaQueryListEvent) => void) => {
transparencyChange = listener
}),
removeEventListener: vi.fn(),
}))
vi.stubGlobal(
'Image',
class {
src = ''
decode = vi.fn(
() =>
new Promise<void>((resolve, reject) => {
decodePending.push({ resolve, reject })
}),
)
},
)
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
shell.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})
async function settle() {
await vi.advanceTimersByTimeAsync(65)
completePendingDecode()
await flushPromises()
}
function expectReadyForWidth(expectedWidth: number) {
expect(shell.dataset.glassNavbarRefractionReady).toBe('true')
expect(wrapper?.get('feImage').attributes('width')).toBe(String(expectedWidth))
}
function completePendingDecode() {
for (const pending of decodePending.splice(0)) pending.resolve()
}
function dispatchTransition(type: 'transitionrun' | 'transitionend' | 'transitioncancel', propertyName: string) {
const event = new Event(type, { bubbles: true }) as TransitionEvent
Object.defineProperty(event, 'propertyName', { value: propertyName })
navbar.dispatchEvent(event)
}
it('uses computed pixel radius and activates only a decoded map with matching dimensions', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith({ width: 1423, height: 64, radius: 16 })
expectReadyForWidth(1423)
})
it('disables old sampling during resize and caches unchanged geometry', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
resize?.([], {} as ResizeObserver)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
width = 1200
resize?.([], {} as ResizeObserver)
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
expectReadyForWidth(1200)
})
it('does not generate a map in CSS quality', async () => {
document.documentElement.dataset.glassQuality = 'css'
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
})
it('does not activate a pending map after switching to CSS quality', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await vi.advanceTimersByTimeAsync(65)
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
document.documentElement.dataset.glassQuality = 'css'
await flushPromises()
completePendingDecode()
await flushPromises()
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
})
it('does not generate or activate a map while reduced transparency is enabled', async () => {
transparencyReduced = true
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
expect(createGlassNavbarDisplacementMap).not.toHaveBeenCalled()
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
transparencyReduced = false
transparencyChange?.({ matches: false } as MediaQueryListEvent)
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
expectReadyForWidth(1423)
})
it('regenerates after a geometry transition ends or is cancelled', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
width = 1200
dispatchTransition('transitionrun', 'width')
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
dispatchTransition('transitionend', 'width')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
expectReadyForWidth(1200)
radius = 20
dispatchTransition('transitionrun', 'border-radius')
dispatchTransition('transitioncancel', 'border-radius')
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 20 })
expectReadyForWidth(1200)
})
it('waits for every geometry transition before restoring the map', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
dispatchTransition('transitionrun', 'width')
dispatchTransition('transitionrun', 'border-radius')
dispatchTransition('transitionend', 'width')
await vi.advanceTimersByTimeAsync(65)
expect(shell.dataset.glassNavbarRefractionReady).toBe('false')
dispatchTransition('transitioncancel', 'border-radius')
await settle()
expectReadyForWidth(1423)
})
it('only regenerates for radius or observed theme size changes', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
shell.style.setProperty('--shell-floating-navbar-scale-x', '0.9')
await flushPromises()
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
radius = 20
shell.style.setProperty('--shell-floating-navbar-radius', '20px')
await flushPromises()
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1423, height: 64, radius: 20 })
})
it('does not retry a failed geometry in a feedback loop', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await vi.advanceTimersByTimeAsync(65)
const [pending] = decodePending.splice(0)
pending.reject(new Error('decode failed'))
await flushPromises()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
resize?.([], {} as ResizeObserver)
await settle()
resize?.([], {} as ResizeObserver)
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
})
it('uses the window resize fallback and removes it on unmount', async () => {
vi.stubGlobal('ResizeObserver', undefined)
wrapper = mount(GlassNavbarRefractionDefs)
await settle()
expect(observe).not.toHaveBeenCalled()
width = 1200
window.dispatchEvent(new Event('resize'))
await settle()
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith({ width: 1200, height: 64, radius: 16 })
wrapper.unmount()
wrapper = undefined
width = 1100
window.dispatchEvent(new Event('resize'))
await settle()
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
})
it('drops pending work on unmount', async () => {
wrapper = mount(GlassNavbarRefractionDefs)
await vi.advanceTimersByTimeAsync(65)
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
wrapper.unmount()
wrapper = undefined
completePendingDecode()
await flushPromises()
expect(shell.hasAttribute('data-glass-navbar-refraction-ready')).toBe(false)
expect(disconnect).toHaveBeenCalled()
})
})
@@ -393,10 +393,17 @@ describe('glass overlay material styles', () => {
) )
}) })
it('scopes live navbar refraction to Chromium clear and tinted floating shells', () => { it('keeps floating clear and tinted navbars on CSS material until Chromium SVG is ready', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8') const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8') const filterDefinitions = readFileSync(resolve(cwd(), 'src/components/theme/GlassNavbarRefractionDefs.vue'), 'utf8')
const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8') const refractionUtilities = readFileSync(resolve(cwd(), 'src/utils/glassNavbarRefraction.ts'), 'utf8')
const baseMaterialStart = styles.lastIndexOf('// 基础材质由单一真实表面承载')
const svgEnhancementStart = styles.indexOf('// 只有已确认的 Chromium SVG 能力')
const reducedTransparencyStart = styles.lastIndexOf('@media (prefers-reduced-transparency: reduce)')
const reducedMotionStart = styles.lastIndexOf('@media (prefers-reduced-motion: reduce)')
const baseMaterialRule = styles.slice(baseMaterialStart, svgEnhancementStart)
const reducedTransparencyRule = styles.slice(reducedTransparencyStart, reducedMotionStart)
const reducedMotionRule = styles.slice(reducedMotionStart)
expect(styles).toContain("data-glass-navbar-refraction='chromium'") expect(styles).toContain("data-glass-navbar-refraction='chromium'")
expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')") expect(styles).toContain("url('#glass-navbar-live-refraction-balanced')")
@@ -419,27 +426,46 @@ describe('glass overlay material styles', () => {
expect(styles).toContain( expect(styles).toContain(
'inset-inline-start 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)',
) )
const highQualityStart = styles.indexOf("html[data-theme='glass'][data-glass-quality='high']") expect(baseMaterialStart).toBeGreaterThanOrEqual(0)
const liveRefractionStart = styles.lastIndexOf( expect(svgEnhancementStart).toBeGreaterThan(baseMaterialStart)
"html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])", expect(baseMaterialRule).toContain('.layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top')
highQualityStart, expect(baseMaterialRule).not.toContain("data-glass-navbar-refraction='chromium'")
expect(styles).toContain('--glass-navbar-live-filter: none;')
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(1.5px) saturate(118%)')
expect(baseMaterialRule).toContain('-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important')
expect(baseMaterialRule).toContain('backdrop-filter: var(--glass-navbar-live-filter) !important')
expect(baseMaterialRule).toContain('border: 0 !important')
expect(baseMaterialRule).toContain('linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%)')
expect(baseMaterialRule).toContain('inset 0 1px 2px rgba(255, 255, 255, 0.4)')
expect(baseMaterialRule).not.toContain('inset 0 1px 0 rgba(255, 255, 255, 0.4)')
expect(baseMaterialRule).not.toContain("url('#glass-navbar-live-refraction-")
expect(styles).toMatch(
/html\[data-theme='glass'\]\[data-glass-quality='balanced'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-balanced'\) blur\(1\.5px\) saturate\(118%\);/,
)
expect(styles).toMatch(
/html\[data-theme='glass'\]\[data-glass-quality='high'\]:is\([\s\S]*?\.layout-wrapper\[data-glass-navbar-refraction='chromium'\]\[data-glass-navbar-refraction-ready='true'\]\.layout-navbar-floating-eligible\.layout-navbar-away-from-top\s+\.layout-navbar\s*\{[\s\S]*?--glass-navbar-live-filter: url\('#glass-navbar-live-refraction-high'\) blur\(1px\) saturate\(122%\);/,
)
expect(styles).not.toMatch(
/data-glass-navbar-refraction='chromium'\](?!\[data-glass-navbar-refraction-ready='true'\])[^{]*\{[\s\S]*?url\('#glass-navbar-live-refraction-/u,
) )
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('inline-size: min(100vw, variables.$layout-boxed-content-width)')
expect(styles).toContain('transform: translateX(-50%) !important') expect(styles).toContain('transform: translateX(-50%) !important')
expect(styles).toMatch( expect(styles).toMatch(
/\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/, /\[data-glass-appearance='tinted'\][\s\S]*?\.layout-navbar\s*\{[\s\S]*?--glass-material-accent-rgb/,
) )
expect(liveRefractionRule).not.toContain('&::before') expect(baseMaterialRule).not.toContain('&::before')
expect(styles).not.toContain('&::after') expect(styles).not.toContain('&::after')
expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']") expect(styles).not.toContain("[data-glass-appearance='frosted'][data-glass-navbar-refraction='chromium']")
expect(reducedTransparencyRule).toContain('--glass-navbar-live-filter: none !important')
expect(reducedTransparencyRule).toContain('-webkit-backdrop-filter: none !important')
expect(reducedTransparencyRule).toContain('backdrop-filter: none !important')
expect(reducedTransparencyRule).toContain('background: rgb(11, 19, 34) !important')
expect(reducedTransparencyRule).toContain('background-image: none !important')
expect(reducedMotionRule).toContain('.layout-navbar')
expect(reducedMotionRule).toContain('.navbar-content-container')
expect(reducedMotionRule).toContain('transition: none !important')
expect(reducedMotionRule).not.toContain('inset-block-start: 0 !important')
expect(reducedMotionRule).not.toContain('inset-inline: 0 !important')
expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap') expect(filterDefinitions).toContain('createGlassNavbarDisplacementMap')
expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP') expect(filterDefinitions).toContain('NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP')
expect(filterDefinitions).toContain('getBoundingClientRect()') expect(filterDefinitions).toContain('getBoundingClientRect()')
@@ -447,20 +473,24 @@ describe('glass overlay material styles', () => {
expect(filterDefinitions).toContain('in2="map"') expect(filterDefinitions).toContain('in2="map"')
expect(filterDefinitions).toContain(':width="displacementMapSize.width"') expect(filterDefinitions).toContain(':width="displacementMapSize.width"')
expect(filterDefinitions).toContain(':height="displacementMapSize.height"') expect(filterDefinitions).toContain(':height="displacementMapSize.height"')
expect(filterDefinitions).not.toContain('width="100%"') expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bwidth="100%"/u)
expect(filterDefinitions).not.toContain('height="100%"') expect(filterDefinitions).not.toMatch(/<feImage\b[^>]*\bheight="100%"/u)
expect(filterDefinitions).toContain('scale="-22"') expect(filterDefinitions).toContain('scale="-22"')
expect(filterDefinitions).toContain('scale="-34"') expect(filterDefinitions).toContain('scale="-34"')
expect(filterDefinitions).toContain('x="-8%"') expect(filterDefinitions).toContain('x="0%"')
expect(filterDefinitions).toContain('width="116%"') expect(filterDefinitions).toContain('y="0%"')
expect(filterDefinitions).toContain('x="-12%"') expect(filterDefinitions).toContain('width="100%"')
expect(filterDefinitions).toContain('width="124%"') expect(filterDefinitions).toContain('height="100%"')
expect(filterDefinitions).not.toContain('x="-8%"')
expect(filterDefinitions).not.toContain('width="116%"')
expect(filterDefinitions).not.toContain('x="-12%"')
expect(filterDefinitions).not.toContain('width="124%"')
expect(filterDefinitions).not.toContain('horizontal-continuity') expect(filterDefinitions).not.toContain('horizontal-continuity')
expect(filterDefinitions).not.toContain('<feComposite') expect(filterDefinitions).not.toContain('<feComposite')
expect(filterDefinitions).not.toContain('<feGaussianBlur') expect(filterDefinitions).not.toContain('<feGaussianBlur')
expect(refractionUtilities).toContain('createGlassNavbarDisplacementField') expect(refractionUtilities).toContain('createGlassNavbarDisplacementField')
expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX') expect(refractionUtilities).toContain('OUTER_NEUTRAL_GUARD_PX')
expect(refractionUtilities).toContain('Math.sin(Math.PI * normalizedDistance)') expect(refractionUtilities).toContain('refractionProfile(distanceInside, bandWidth, outerGuard)')
expect(refractionUtilities).toContain("canvas.toDataURL('image/png')") expect(refractionUtilities).toContain("canvas.toDataURL('image/png')")
}) })
}) })
+55 -14
View File
@@ -53,6 +53,7 @@ html[data-theme='glass'] {
--glass-control-prominent-backdrop-filter: none; --glass-control-prominent-backdrop-filter: none;
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter); --glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%); --glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
--glass-navbar-live-filter: none;
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)); --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-blur: var(--glass-overlay-clarity-blur, 6px);
--glass-overlay-saturate: 115%; --glass-overlay-saturate: 115%;
@@ -1652,10 +1653,10 @@ html[data-theme='glass'] body[data-theme='glass'] .native-login-field:focus-with
} }
} }
// 实时折射表面在贴顶与浮动状态使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。 // 浮动顶栏使用同一布局坐标系,只插值真实 inset,避免切换 transform 采样空间。
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
body[data-theme='glass'] body[data-theme='glass']
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible .layout-wrapper.layout-navbar-floating-eligible
.layout-navbar { .layout-navbar {
inline-size: auto !important; inline-size: auto !important;
inset-block-start: 0 !important; inset-block-start: 0 !important;
@@ -1680,42 +1681,82 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
} }
} }
// 位移、材质和轮廓由同一个真实尺寸的表面完成;顶部不叠加硬高光线 // 基础材质由单一真实表面承载;柔和迎光表达厚度,不叠加父子两层硬高光。
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted']) html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
body[data-theme='glass'] body[data-theme='glass']
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar { .layout-navbar {
--glass-navbar-live-filter: blur(1.5px) saturate(118%);
border: 0 !important; border: 0 !important;
-webkit-backdrop-filter: blur(1.5px) saturate(118%) !important; -webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
backdrop-filter: url('#glass-navbar-live-refraction-balanced') blur(1.5px) saturate(118%) !important; backdrop-filter: var(--glass-navbar-live-filter) !important;
background: background:
linear-gradient(145deg, rgba(255, 255, 255, 0.13), transparent 34%), linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important; linear-gradient(rgba(7, 14, 25, 0.06), rgba(7, 14, 25, 0.13)) !important;
box-shadow: box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(255, 255, 255, 0.24),
inset -1px 0 0 rgba(255, 255, 255, 0.16), inset 0 1px 2px rgba(255, 255, 255, 0.4),
inset 0 -1px 0 rgba(4, 10, 20, 0.22), inset 0 -1px 2px rgba(4, 10, 20, 0.22),
0 12px 32px rgba(3, 7, 18, 0.16) !important; 0 12px 32px rgba(3, 7, 18, 0.16) !important;
inset-block-start: var(--shell-floating-navbar-inset) !important; inset-block-start: var(--shell-floating-navbar-inset) !important;
inset-inline: var(--shell-floating-navbar-inset) !important; inset-inline: var(--shell-floating-navbar-inset) !important;
} }
// 只有已确认的 Chromium SVG 能力才替换基础 CSS 滤镜;未就绪时保持同族材质与真实几何。
html[data-theme='glass'][data-glass-quality='high']:is( html[data-theme='glass'][data-glass-quality='high']:is(
[data-glass-appearance='clear'], [data-glass-appearance='clear'],
[data-glass-appearance='tinted'] [data-glass-appearance='tinted']
) )
body[data-theme='glass'] 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'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar { .layout-navbar {
backdrop-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%) !important; --glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(1px) saturate(122%);
}
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-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(1.5px) saturate(118%);
} }
html[data-theme='glass'][data-glass-appearance='tinted'] html[data-theme='glass'][data-glass-appearance='tinted']
body[data-theme='glass'] body[data-theme='glass']
.layout-wrapper[data-glass-navbar-refraction='chromium'].layout-navbar-floating-eligible.layout-navbar-away-from-top .layout-wrapper.layout-navbar-floating-eligible.layout-navbar-away-from-top
.layout-navbar { .layout-navbar {
background: background:
linear-gradient(145deg, rgba(255, 255, 255, 0.14), transparent 36%), linear-gradient(145deg, rgba(255, 255, 255, 0.18), transparent 38%),
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.09), rgba(var(--glass-material-accent-rgb), 0.035)), 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)) !important; linear-gradient(rgba(7, 14, 25, 0.08), rgba(7, 14, 25, 0.15)) !important;
} }
@media (prefers-reduced-transparency: reduce) {
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
.layout-navbar {
--glass-navbar-live-filter: none !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background: rgb(11, 19, 34) !important;
background-image: none !important;
}
}
@media (prefers-reduced-motion: reduce) {
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,
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
body[data-theme='glass']
.layout-wrapper.layout-navbar-floating-eligible
.navbar-content-container {
transition: none !important;
}
}
@@ -15,10 +15,50 @@ describe('createGlassNavbarDisplacementField', () => {
expect(field.height).toBe(41) expect(field.height).toBe(41)
expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255]) expect(pixelAt(field, 50, 0)).toEqual([128, 128, 128, 255])
expect(pixelAt(field, 50, 20)).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, 50, 5)[2]).toBeLessThan(120)
expect(pixelAt(field, 5, 20)[0]).toBeLessThan(64) expect(pixelAt(field, 5, 20)[0]).toBeLessThan(120)
expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(192) expect(pixelAt(field, 95, 20)[0]).toBeGreaterThan(136)
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(192) expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(136)
})
it.each([
{ width: 1423, height: 64, radius: 16 },
{ width: 401, height: 72, radius: 16 },
{ width: 127, height: 64, radius: 8 },
{ width: 127, height: 64, radius: 32 },
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
const field = createGlassNavbarDisplacementField(geometry)
for (const scale of [-22, -34]) {
const source = (x: number, y: number) => {
const pixel = pixelAt(field, x, y)
return [x + 0.5 + scale * (pixel[0] / 255 - 0.5), y + 0.5 + scale * (pixel[2] / 255 - 0.5)]
}
let minimumDeterminant = Number.POSITIVE_INFINITY
let minimumX = Number.POSITIVE_INFINITY
let minimumY = Number.POSITIVE_INFINITY
let maximumX = 0
let maximumY = 0
for (let y = 0; y < field.height; y += 1) {
for (let x = 0; x < field.width; x += 1) {
const point = source(x, y)
minimumX = Math.min(minimumX, point[0])
minimumY = Math.min(minimumY, point[1])
maximumX = Math.max(maximumX, point[0])
maximumY = Math.max(maximumY, point[1])
if (x === field.width - 1 || y === field.height - 1) continue
const nextX = source(x + 1, y)
const nextY = source(x, y + 1)
const determinant =
(nextX[0] - point[0]) * (nextY[1] - point[1]) - (nextY[0] - point[0]) * (nextX[1] - point[1])
minimumDeterminant = Math.min(minimumDeterminant, determinant)
}
}
expect(minimumDeterminant).toBeGreaterThan(0.05)
expect(minimumX).toBeGreaterThanOrEqual(0)
expect(minimumY).toBeGreaterThanOrEqual(0)
expect(maximumX).toBeLessThanOrEqual(field.width)
expect(maximumY).toBeLessThanOrEqual(field.height)
}
}) })
it('clamps invalidly small geometry to a renderable pixel surface', () => { it('clamps invalidly small geometry to a renderable pixel surface', () => {
+29 -10
View File
@@ -31,10 +31,12 @@ 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' '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_NEUTRAL_CHANNEL = 128
const DISPLACEMENT_CHANNEL_AMPLITUDE = 127 const HIGH_REFRACTION_SCALE_PX = 34
const OUTER_NEUTRAL_GUARD_PX = 2 const OUTER_NEUTRAL_GUARD_PX = 0.5
const REFRACTION_BAND_PX = 12 const REFRACTION_BAND_PX = 24
const REFRACTION_PROFILE_POWER = 2 // 峰值靠近外沿,内侧有足够距离释放放大率;对称波峰会在窄轮廓内反向采样。
const MAX_DISPLACEMENT_BAND_RATIO = 0.42
const PEAK_DEPTH_RATIO = 0.16
function normalizePixelSize(value: number) { function normalizePixelSize(value: number) {
return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1 return Number.isFinite(value) ? Math.max(1, Math.round(value)) : 1
@@ -53,6 +55,17 @@ function clampChannel(value: number) {
return Math.max(0, Math.min(255, Math.round(value))) return Math.max(0, Math.min(255, Math.round(value)))
} }
function smoothstep(value: number) {
return value * value * (3 - 2 * value)
}
/** 外侧快速形成厚度,内侧缓慢回到中性,保留清透中心且不折返背景。 */
function refractionProfile(depth: number, band: number, guard: number) {
const peakDepth = Math.max(guard, band * PEAK_DEPTH_RATIO)
if (depth <= peakDepth) return smoothstep((depth - guard) / (peakDepth - guard))
return 1 - smoothstep((depth - peakDepth) / (band - peakDepth))
}
/** /**
* 生成圆角表面的法线位移场。 * 生成圆角表面的法线位移场。
* 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。 * 外轮廓和内区都保持中性采样,避免折射在裁剪边界或主体内容区形成整带错位。
@@ -66,8 +79,7 @@ export function createGlassNavbarDisplacementField({
const pixelHeight = normalizePixelSize(height) const pixelHeight = normalizePixelSize(height)
const maxRadius = Math.min(pixelWidth, pixelHeight) / 2 const maxRadius = Math.min(pixelWidth, pixelHeight) / 2
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
const bandWidth = Math.min(REFRACTION_BAND_PX, Math.min(pixelWidth, pixelHeight) / 2) const maximumBand = Math.min(REFRACTION_BAND_PX, pixelRadius * 1.5, Math.min(pixelWidth, pixelHeight) / 2)
const outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
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) {
@@ -83,11 +95,18 @@ export function createGlassNavbarDisplacementField({
const sampleY = y + 0.5 const sampleY = y + 0.5
const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius) const signedDistance = roundedRectangleSignedDistance(sampleX, sampleY, pixelWidth, pixelHeight, pixelRadius)
const distanceInside = -signedDistance const distanceInside = -signedDistance
// 长直边允许更厚的透镜;向圆角与法线交汇轴渐缩,避免高曲率区产生聚焦尖点。
const edgeX = Math.min(sampleX, pixelWidth - sampleX)
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 outerGuard = Math.min(OUTER_NEUTRAL_GUARD_PX, bandWidth / 4)
const channelAmplitude = (bandWidth * MAX_DISPLACEMENT_BAND_RATIO * 255) / HIGH_REFRACTION_SCALE_PX
if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue if (signedDistance > 0 || distanceInside <= outerGuard || distanceInside >= bandWidth) continue
const normalizedDistance = (distanceInside - outerGuard) / (bandWidth - outerGuard) const profile = refractionProfile(distanceInside, bandWidth, outerGuard)
const profile = Math.sin(Math.PI * normalizedDistance) ** REFRACTION_PROFILE_POWER
const gradientX = const gradientX =
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) - roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) roundedRectangleSignedDistance(sampleX - 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius)
@@ -98,10 +117,10 @@ export function createGlassNavbarDisplacementField({
const offset = (y * pixelWidth + x) * 4 const offset = (y * pixelWidth + x) * 4
pixels[offset] = clampChannel( pixels[offset] = clampChannel(
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientX * profile) / gradientLength, DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientX * profile) / gradientLength,
) )
pixels[offset + 2] = clampChannel( pixels[offset + 2] = clampChannel(
DISPLACEMENT_NEUTRAL_CHANNEL + (DISPLACEMENT_CHANNEL_AMPLITUDE * gradientY * profile) / gradientLength, DISPLACEMENT_NEUTRAL_CHANNEL + (channelAmplitude * gradientY * profile) / gradientLength,
) )
} }
} }