mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 02:06:44 +08:00
feat(glass): extend blur-free panels and defer inactive GPU work
This commit is contained in:
@@ -3,6 +3,7 @@ import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
||||
import GlassPanelRefractionDefs from '@/components/theme/GlassPanelRefractionDefs.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
@@ -239,6 +240,7 @@ export default defineComponent({
|
||||
},
|
||||
[
|
||||
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
||||
navbarRefractionMode === 'chromium' ? h(GlassPanelRefractionDefs) : null,
|
||||
fixedShellBackplateNode,
|
||||
verticalNav,
|
||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
||||
|
||||
@@ -108,6 +108,10 @@ vi.mock('@/components/theme/GlassNavbarRefractionDefs.vue', () => ({
|
||||
default: { template: '<svg data-testid="navbar-refraction-defs" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/theme/GlassPanelRefractionDefs.vue', () => ({
|
||||
default: { template: '<svg data-testid="panel-refraction-defs" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@layouts/components/VerticalNav.vue', () => ({
|
||||
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
||||
}))
|
||||
@@ -198,6 +202,7 @@ describe('VerticalNavLayout shell states', () => {
|
||||
|
||||
expect(goal1Wrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('goal1')
|
||||
expect(goal1Wrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(false)
|
||||
expect(goal1Wrapper.find('[data-testid="panel-refraction-defs"]').exists()).toBe(false)
|
||||
goal1Wrapper.unmount()
|
||||
|
||||
mocks.navbarRefractionSupported = true
|
||||
@@ -205,6 +210,7 @@ describe('VerticalNavLayout shell states', () => {
|
||||
|
||||
expect(chromiumWrapper.get('.layout-wrapper').attributes('data-glass-navbar-refraction')).toBe('chromium')
|
||||
expect(chromiumWrapper.find('[data-testid="navbar-refraction-defs"]').exists()).toBe(true)
|
||||
expect(chromiumWrapper.find('[data-testid="panel-refraction-defs"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the footer contract stable across App and drawer shells', async () => {
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
createGlassNavbarDisplacementMap,
|
||||
createGlassPanelBackdropMap,
|
||||
getGlassSidebarOpticalResponse,
|
||||
NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP,
|
||||
supportsGlassNavbarLiveRefraction,
|
||||
} from '@/utils/glassNavbarRefraction'
|
||||
import { getGlassMaterialResponse } from '@/utils/glassOptics'
|
||||
|
||||
/** 内容面使用真实背景,固定磨砂导航复用稳定背板。 */
|
||||
type SurfaceKind = 'card' | 'navbar' | 'sidebar' | 'backplate'
|
||||
|
||||
interface FilterDefinition {
|
||||
/** 当前组件内稳定且唯一的 SVG 引用。 */
|
||||
id: string
|
||||
/** 与位移图一致的 CSS 像素宽度。 */
|
||||
width: number
|
||||
/** 与位移图一致的 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 已解码的位移和散射权重图。 */
|
||||
image: string
|
||||
}
|
||||
|
||||
interface SurfaceBinding {
|
||||
/** 表面拥有者;前景内容不进入滤镜输入。 */
|
||||
element: HTMLElement
|
||||
/** 决定背景滤镜或稳定壁纸滤镜的承载方式。 */
|
||||
kind: SurfaceKind
|
||||
/** 表面对应的独立滤镜标识。 */
|
||||
id: string
|
||||
/** 用于检测真实几何改变的缓存键。 */
|
||||
key: string
|
||||
/** 只恢复本组件仍拥有的内联声明,避免覆盖其他运行态写入。 */
|
||||
styles: Map<string, { previous: string; priority: string; applied: string }>
|
||||
/** 原有表面标记,解除接管时恢复。 */
|
||||
previousMarker: string | null
|
||||
/** 背景所有权在解码前发布,避免质量切换时短暂叠加 WebGL 静态背景。 */
|
||||
previousOwner: string | null
|
||||
}
|
||||
|
||||
const definitions = shallowRef<FilterDefinition[]>([])
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
const settings = useEffectiveGlassSettings()
|
||||
const prefix = `glass-panel-${getCurrentInstance()?.uid ?? 0}`
|
||||
const optics = computed(() =>
|
||||
getGlassSidebarOpticalResponse({
|
||||
deformation: settings.value.glassDeformationStrength,
|
||||
translation: settings.value.glassTranslationStrength,
|
||||
}),
|
||||
)
|
||||
const bodyBlur = computed(() => {
|
||||
if (settings.value.glassAppearance !== 'frosted') return 0
|
||||
const material = getGlassMaterialResponse(settings.value.glassAppearance, settings.value.glassTransparencyStrength)
|
||||
return (settings.value.glassQuality === 'high' ? 10 : 16) * material.frostBlurScale
|
||||
})
|
||||
const scale = computed(() => {
|
||||
const response = optics.value
|
||||
if (!response.horizontalRatio && !response.verticalRatio && !response.translationPx) return 0
|
||||
return settings.value.glassQuality === 'high' ? -34 : -22
|
||||
})
|
||||
const surfaces = new Map<HTMLElement, SurfaceBinding>()
|
||||
const imageCache = new Map<string, Promise<string>>()
|
||||
const geometryAnchors = new Set<HTMLElement>()
|
||||
const nearbyCards = new Set<HTMLElement>()
|
||||
// 提前一个短滚动距离准备材质;远处卡片不分配 SVG 滤镜和全尺寸位移图。
|
||||
const CARD_PREWARM_MARGIN = 256
|
||||
let shell: HTMLElement | null = null
|
||||
let observer: MutationObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let reducedTransparency: MediaQueryList | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let revision = 0
|
||||
let sequence = 0
|
||||
let disposed = false
|
||||
|
||||
function setStyle(binding: SurfaceBinding, property: string, value: string) {
|
||||
const style = binding.element.style
|
||||
if (!binding.styles.has(property)) {
|
||||
binding.styles.set(property, {
|
||||
previous: style.getPropertyValue(property),
|
||||
priority: style.getPropertyPriority(property),
|
||||
applied: value,
|
||||
})
|
||||
}
|
||||
style.setProperty(property, value, 'important')
|
||||
// CSSOM 可能规范化数值与空白;用浏览器实际保存值判断后续写入所有权。
|
||||
binding.styles.get(property)!.applied = style.getPropertyValue(property)
|
||||
}
|
||||
|
||||
function release(binding: SurfaceBinding) {
|
||||
for (const [property, record] of binding.styles) {
|
||||
if (binding.element.style.getPropertyValue(property) !== record.applied) continue
|
||||
if (record.previous) binding.element.style.setProperty(property, record.previous, record.priority)
|
||||
else binding.element.style.removeProperty(property)
|
||||
}
|
||||
binding.styles.clear()
|
||||
if (binding.element.dataset.glassPanelRefraction === binding.id) {
|
||||
if (binding.previousMarker === null) binding.element.removeAttribute('data-glass-panel-refraction')
|
||||
else binding.element.setAttribute('data-glass-panel-refraction', binding.previousMarker)
|
||||
}
|
||||
}
|
||||
|
||||
function forget(binding: SurfaceBinding) {
|
||||
release(binding)
|
||||
if (binding.element.dataset.glassPanelOwner === binding.id) {
|
||||
if (binding.previousOwner === null) binding.element.removeAttribute('data-glass-panel-owner')
|
||||
else binding.element.setAttribute('data-glass-panel-owner', binding.previousOwner)
|
||||
}
|
||||
resizeObserver?.unobserve(binding.element)
|
||||
intersectionObserver?.unobserve(binding.element)
|
||||
nearbyCards.delete(binding.element)
|
||||
}
|
||||
|
||||
function suspend() {
|
||||
revision += 1
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
timer = null
|
||||
for (const binding of surfaces.values()) release(binding)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
suspend()
|
||||
for (const binding of surfaces.values()) forget(binding)
|
||||
for (const element of geometryAnchors) resizeObserver?.unobserve(element)
|
||||
surfaces.clear()
|
||||
geometryAnchors.clear()
|
||||
nearbyCards.clear()
|
||||
definitions.value = []
|
||||
}
|
||||
|
||||
function isEligible() {
|
||||
return (
|
||||
!disposed &&
|
||||
shell?.isConnected &&
|
||||
document.documentElement.dataset.theme === 'glass' &&
|
||||
!reducedTransparency?.matches &&
|
||||
shell?.dataset.shellMode === 'desktop' &&
|
||||
settings.value.glassQuality !== 'css' &&
|
||||
supportsGlassNavbarLiveRefraction()
|
||||
)
|
||||
}
|
||||
|
||||
function canEnhance() {
|
||||
return isEligible() && document.visibilityState !== 'hidden' && document.hasFocus()
|
||||
}
|
||||
|
||||
/** 只收集完整的顶层内容表面,图片、内嵌卡片和弹层保持现有材质。 */
|
||||
function collectSurfaces() {
|
||||
const result = new Map<HTMLElement, SurfaceKind>()
|
||||
if (!shell) return result
|
||||
for (const element of shell.querySelectorAll<HTMLElement>(
|
||||
'.layout-page-content .v-card, .dashboard-grid-content-measure .v-card',
|
||||
)) {
|
||||
if (
|
||||
element.parentElement?.closest('.v-card') ||
|
||||
element.closest('.v-overlay, .no-blur, [data-glass-optical-mode="excluded"]') ||
|
||||
element.matches('.media-card, .playing-card, .bg-primary, .bg-success, .bg-info, .bg-warning, .bg-error')
|
||||
)
|
||||
continue
|
||||
const bounds = element.getBoundingClientRect()
|
||||
if (bounds.width < 4 || bounds.height < 4 || bounds.width * bounds.height > 4_000_000) continue
|
||||
result.set(element, 'card')
|
||||
}
|
||||
if (!shell.matches('.layout-horizontal-nav-active, .layout-window-controls-overlay-shell')) {
|
||||
if (settings.value.glassAppearance === 'frosted') {
|
||||
for (const element of shell.querySelectorAll<HTMLElement>(
|
||||
'.glass-fixed-shell-backplate--main .glass-fixed-shell-backplate__layer',
|
||||
))
|
||||
result.set(element, 'backplate')
|
||||
} else {
|
||||
const header = shell.querySelector<HTMLElement>('.layout-navbar')
|
||||
const sidebar = shell.querySelector<HTMLElement>('.layout-vertical-nav:not(.overlay-nav)')
|
||||
if (header) result.set(header, 'navbar')
|
||||
if (sidebar) result.set(sidebar, 'sidebar')
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function readGeometry(binding: SurfaceBinding) {
|
||||
const bounds = binding.element.getBoundingClientRect()
|
||||
const width = Math.round(bounds.width)
|
||||
const height = Math.round(bounds.height)
|
||||
// 极长滚动容器保留原生材质,不为少量边缘分配整张文档位移图。
|
||||
if (width < 4 || height < 4 || width * height > 4_000_000) return null
|
||||
const radius = Number.parseFloat(getComputedStyle(binding.element).borderTopLeftRadius) || 0
|
||||
const panels =
|
||||
binding.kind === 'backplate'
|
||||
? ['.layout-navbar', '.layout-vertical-nav'].flatMap(selector => {
|
||||
const element = shell?.querySelector<HTMLElement>(selector)
|
||||
if (!element) return []
|
||||
const rect = element.getBoundingClientRect()
|
||||
return [
|
||||
{
|
||||
x: Math.round(rect.left - bounds.left),
|
||||
y: Math.round(rect.top - bounds.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
radius: Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0,
|
||||
},
|
||||
]
|
||||
})
|
||||
: null
|
||||
return { width, height, radius, panels, optics: optics.value }
|
||||
}
|
||||
|
||||
async function decodedMap(geometry: NonNullable<ReturnType<typeof readGeometry>>, key: string) {
|
||||
let pending = imageCache.get(key)
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
const map = geometry.panels
|
||||
? createGlassPanelBackdropMap({ ...geometry, panels: geometry.panels })
|
||||
: createGlassNavbarDisplacementMap({ ...geometry, surface: 'panel' })
|
||||
if (map === NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP) throw new Error('Panel map unavailable')
|
||||
const image = new Image()
|
||||
image.src = map
|
||||
await image.decode()
|
||||
return map
|
||||
})()
|
||||
imageCache.set(key, pending)
|
||||
if (imageCache.size > 24) imageCache.delete(imageCache.keys().next().value!)
|
||||
void pending.catch(() => {
|
||||
if (imageCache.get(key) === pending) imageCache.delete(key)
|
||||
})
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function reconcileBindings() {
|
||||
const candidates = collectSurfaces()
|
||||
for (const [element, binding] of surfaces) {
|
||||
if (!candidates.has(element) || candidates.get(element) !== binding.kind) {
|
||||
forget(binding)
|
||||
surfaces.delete(element)
|
||||
}
|
||||
}
|
||||
for (const [element, kind] of candidates) {
|
||||
if (surfaces.has(element)) continue
|
||||
const binding: SurfaceBinding = {
|
||||
element,
|
||||
kind,
|
||||
id: `${prefix}-${sequence++}`,
|
||||
key: '',
|
||||
styles: new Map(),
|
||||
previousMarker: element.getAttribute('data-glass-panel-refraction'),
|
||||
previousOwner: element.getAttribute('data-glass-panel-owner'),
|
||||
}
|
||||
surfaces.set(element, binding)
|
||||
element.dataset.glassPanelOwner = binding.id
|
||||
if (kind === 'card') {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
if (bounds.bottom >= -CARD_PREWARM_MARGIN && bounds.top <= window.innerHeight + CARD_PREWARM_MARGIN)
|
||||
nearbyCards.add(element)
|
||||
intersectionObserver?.observe(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSurfaces() {
|
||||
timer = null
|
||||
if (!canEnhance()) {
|
||||
suspend()
|
||||
return
|
||||
}
|
||||
const currentRevision = ++revision
|
||||
reconcileBindings()
|
||||
for (const binding of surfaces.values()) resizeObserver?.observe(binding.element)
|
||||
// 稳定背板自身不随侧栏展开改变尺寸,仍需观察其内部导航轮廓。
|
||||
for (const element of shell?.querySelectorAll<HTMLElement>('.layout-navbar, .layout-vertical-nav') ?? []) {
|
||||
if (geometryAnchors.has(element)) continue
|
||||
geometryAnchors.add(element)
|
||||
resizeObserver?.observe(element)
|
||||
}
|
||||
const active: Array<{ binding: SurfaceBinding; definition: FilterDefinition }> = []
|
||||
for (const binding of surfaces.values()) {
|
||||
if (binding.kind === 'card' && intersectionObserver && !nearbyCards.has(binding.element)) {
|
||||
release(binding)
|
||||
continue
|
||||
}
|
||||
const geometry = readGeometry(binding)
|
||||
if (!geometry) {
|
||||
release(binding)
|
||||
continue
|
||||
}
|
||||
const key = JSON.stringify(geometry)
|
||||
if (key !== binding.key) release(binding)
|
||||
try {
|
||||
const image = await decodedMap(geometry, key)
|
||||
if (currentRevision !== revision || !canEnhance()) return
|
||||
binding.key = key
|
||||
active.push({ binding, definition: { id: binding.id, width: geometry.width, height: geometry.height, image } })
|
||||
} catch {
|
||||
if (currentRevision === revision) release(binding)
|
||||
}
|
||||
}
|
||||
// 过期失败也不能清空新一轮 defs,否则已经绑定的新表面会引用不存在的滤镜。
|
||||
if (currentRevision !== revision || !canEnhance()) return
|
||||
definitions.value = active.map(surface => surface.definition)
|
||||
await nextTick()
|
||||
if (currentRevision !== revision || !canEnhance()) return
|
||||
for (const { binding } of active) {
|
||||
const suffix = settings.value.glassQuality === 'balanced' && bodyBlur.value > 0 ? ` blur(${bodyBlur.value}px)` : ''
|
||||
const filter = `url("#${binding.id}")${suffix} saturate(118%) brightness(var(--glass-transmission-brightness, 1))`
|
||||
if (binding.kind === 'backplate') setStyle(binding, 'filter', filter)
|
||||
else if (binding.kind === 'card') {
|
||||
setStyle(binding, 'backdrop-filter', filter)
|
||||
setStyle(binding, '-webkit-backdrop-filter', filter)
|
||||
} else setStyle(binding, '--glass-panel-filter', filter)
|
||||
binding.element.dataset.glassPanelRefraction = binding.id
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (timer !== null) clearTimeout(timer)
|
||||
if (!isEligible()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
reconcileBindings()
|
||||
for (const [element, binding] of surfaces) {
|
||||
if (element.isConnected && shell?.contains(element)) continue
|
||||
forget(binding)
|
||||
surfaces.delete(element)
|
||||
}
|
||||
for (const element of geometryAnchors) {
|
||||
if (element.isConnected && shell?.contains(element)) continue
|
||||
resizeObserver?.unobserve(element)
|
||||
geometryAnchors.delete(element)
|
||||
}
|
||||
if (!canEnhance()) {
|
||||
suspend()
|
||||
return
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
void syncSurfaces()
|
||||
}, 60)
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
() => {
|
||||
suspend()
|
||||
scheduleSync()
|
||||
},
|
||||
{ deep: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
shell = svg.value?.closest<HTMLElement>('.layout-wrapper') ?? null
|
||||
reducedTransparency = window.matchMedia('(prefers-reduced-transparency: reduce)')
|
||||
reducedTransparency.addEventListener('change', scheduleSync)
|
||||
observer = new MutationObserver(records => {
|
||||
if (
|
||||
records.some(record => {
|
||||
if (svg.value?.contains(record.target)) return false
|
||||
if (record.type === 'attributes') return record.target === shell || record.target === document.documentElement
|
||||
return [...record.addedNodes, ...record.removedNodes].some(
|
||||
node =>
|
||||
node instanceof HTMLElement &&
|
||||
(node.matches('.v-card, .glass-fixed-shell-backplate__layer') ||
|
||||
node.querySelector('.v-card, .glass-fixed-shell-backplate__layer')),
|
||||
)
|
||||
})
|
||||
)
|
||||
scheduleSync()
|
||||
})
|
||||
if (shell)
|
||||
observer.observe(shell, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-shell-mode'],
|
||||
})
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'data-theme-radius'],
|
||||
})
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!canEnhance()) return
|
||||
let changed = false
|
||||
for (const binding of surfaces.values()) {
|
||||
const geometry = readGeometry(binding)
|
||||
if (!geometry || JSON.stringify(geometry) !== binding.key) {
|
||||
release(binding)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) revision += 1
|
||||
scheduleSync()
|
||||
})
|
||||
}
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const element = entry.target as HTMLElement
|
||||
if (!surfaces.has(element)) continue
|
||||
if (entry.isIntersecting === nearbyCards.has(element)) continue
|
||||
if (entry.isIntersecting) nearbyCards.add(element)
|
||||
else nearbyCards.delete(element)
|
||||
changed = true
|
||||
}
|
||||
if (changed) scheduleSync()
|
||||
},
|
||||
{ rootMargin: `${CARD_PREWARM_MARGIN}px 0px` },
|
||||
)
|
||||
}
|
||||
window.addEventListener('resize', scheduleSync, { passive: true })
|
||||
window.addEventListener('focus', scheduleSync)
|
||||
window.addEventListener('blur', suspend)
|
||||
document.addEventListener('visibilitychange', scheduleSync)
|
||||
scheduleSync()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
reset()
|
||||
observer?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
intersectionObserver?.disconnect()
|
||||
reducedTransparency?.removeEventListener('change', scheduleSync)
|
||||
window.removeEventListener('resize', scheduleSync)
|
||||
window.removeEventListener('focus', scheduleSync)
|
||||
window.removeEventListener('blur', suspend)
|
||||
document.removeEventListener('visibilitychange', scheduleSync)
|
||||
surfaces.clear()
|
||||
geometryAnchors.clear()
|
||||
imageCache.clear()
|
||||
})
|
||||
|
||||
onDeactivated(reset)
|
||||
onActivated(scheduleSync)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="svg" class="glass-panel-refraction-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<filter
|
||||
v-for="definition in definitions"
|
||||
:id="definition.id"
|
||||
:key="definition.id"
|
||||
x="0"
|
||||
y="0"
|
||||
:width="definition.width"
|
||||
:height="definition.height"
|
||||
filterUnits="userSpaceOnUse"
|
||||
primitiveUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feImage
|
||||
x="0"
|
||||
y="0"
|
||||
:width="definition.width"
|
||||
:height="definition.height"
|
||||
:href="definition.image"
|
||||
preserveAspectRatio="none"
|
||||
result="map"
|
||||
/>
|
||||
<template v-if="settings.glassQuality === 'high' && settings.glassAppearance === 'frosted'">
|
||||
<feGaussianBlur in="SourceGraphic" :stdDeviation="bodyBlur" edgeMode="duplicate" result="diffused" />
|
||||
<feDisplacementMap
|
||||
in="diffused"
|
||||
in2="map"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="B"
|
||||
:scale="scale"
|
||||
result="body"
|
||||
/>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="map"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="B"
|
||||
:scale="scale"
|
||||
result="edge"
|
||||
/>
|
||||
<feColorMatrix in="map" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0" result="body-mask" />
|
||||
<feComposite in="body" in2="body-mask" operator="in" result="body-slice" />
|
||||
<feComposite in="edge" in2="body-mask" operator="out" result="edge-slice" />
|
||||
<feComposite in="body-slice" in2="edge-slice" operator="arithmetic" k2="1" k3="1" />
|
||||
</template>
|
||||
<feDisplacementMap
|
||||
v-else
|
||||
in="SourceGraphic"
|
||||
in2="map"
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="B"
|
||||
:scale="scale"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-panel-refraction-defs {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,538 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import GlassPanelRefractionDefs from '../GlassPanelRefractionDefs.vue'
|
||||
import { createGlassNavbarDisplacementMap, createGlassPanelBackdropMap } from '@/utils/glassNavbarRefraction'
|
||||
|
||||
vi.mock('@/utils/glassNavbarRefraction', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('@/utils/glassNavbarRefraction')>()),
|
||||
createGlassNavbarDisplacementMap: vi.fn(() => 'data:image/png;base64,panel'),
|
||||
createGlassPanelBackdropMap: vi.fn(() => 'data:image/png;base64,backplate'),
|
||||
}))
|
||||
|
||||
const effectiveSettings = ref({
|
||||
glassAppearance: 'clear' as 'clear' | 'frosted' | 'tinted',
|
||||
glassDeformationStrength: 48,
|
||||
glassQuality: 'high' as 'balanced' | 'high' | 'css',
|
||||
glassTransparencyStrength: 48,
|
||||
glassTranslationStrength: 48,
|
||||
})
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({ useEffectiveGlassSettings: () => effectiveSettings }))
|
||||
|
||||
const CHROME_USER_AGENT = 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36'
|
||||
const FIREFOX_USER_AGENT = 'Mozilla/5.0 Firefox/142.0'
|
||||
const unsupportedStyleProperties = new Set(['backdrop-filter', '-webkit-backdrop-filter'])
|
||||
const nativeSetProperty = CSSStyleDeclaration.prototype.setProperty
|
||||
const nativeGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue
|
||||
const nativeGetPropertyPriority = CSSStyleDeclaration.prototype.getPropertyPriority
|
||||
const nativeRemoveProperty = CSSStyleDeclaration.prototype.removeProperty
|
||||
const unsupportedStyleValues = new WeakMap<CSSStyleDeclaration, Map<string, { priority: string; value: string }>>()
|
||||
|
||||
describe('GlassPanelRefractionDefs', () => {
|
||||
let shell: HTMLDivElement
|
||||
let card: HTMLElement
|
||||
let wrapper: ReturnType<typeof mount> | undefined
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
let intersect: IntersectionObserverCallback | undefined
|
||||
let panelWidth: number
|
||||
let visibility: DocumentVisibilityState
|
||||
let focused: boolean
|
||||
let browser: 'chrome' | 'firefox'
|
||||
let reducedTransparency: boolean
|
||||
let decodePending: Array<{ resolve: () => void; reject: (reason?: unknown) => void }>
|
||||
const disconnect = vi.fn()
|
||||
const observe = vi.fn()
|
||||
const unobserve = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
panelWidth = 480
|
||||
visibility = 'visible'
|
||||
focused = true
|
||||
browser = 'chrome'
|
||||
reducedTransparency = false
|
||||
decodePending = []
|
||||
effectiveSettings.value = {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 48,
|
||||
glassQuality: 'high',
|
||||
glassTransparencyStrength: 48,
|
||||
glassTranslationStrength: 48,
|
||||
}
|
||||
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibility)
|
||||
vi.spyOn(document, 'hasFocus').mockImplementation(() => focused)
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockImplementation(() =>
|
||||
browser === 'chrome' ? CHROME_USER_AGENT : FIREFOX_USER_AGENT,
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
let left = 24
|
||||
let top = 120
|
||||
let width = panelWidth
|
||||
let height = 240
|
||||
|
||||
if (this.classList.contains('layout-navbar')) {
|
||||
left = 0
|
||||
top = 0
|
||||
width = 1200
|
||||
height = 64
|
||||
} else if (this.classList.contains('layout-vertical-nav')) {
|
||||
left = 0
|
||||
top = 64
|
||||
width = 260
|
||||
height = 800
|
||||
} else if (
|
||||
this.classList.contains('glass-fixed-shell-backplate') ||
|
||||
this.classList.contains('glass-fixed-shell-backplate__layer')
|
||||
) {
|
||||
left = 0
|
||||
top = 0
|
||||
width = 1200
|
||||
height = 900
|
||||
}
|
||||
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
})
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation(element => {
|
||||
const radius = element.classList.contains('layout-vertical-nav')
|
||||
? 0
|
||||
: element.classList.contains('v-card')
|
||||
? 12
|
||||
: 16
|
||||
return {
|
||||
borderTopLeftRadius: `${radius}px`,
|
||||
getPropertyValue: (property: string) => (property === 'border-top-left-radius' ? `${radius}px` : ''),
|
||||
} as unknown as CSSStyleDeclaration
|
||||
})
|
||||
vi.spyOn(CSSStyleDeclaration.prototype, 'setProperty').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
property,
|
||||
value,
|
||||
priority = '',
|
||||
) {
|
||||
if (unsupportedStyleProperties.has(property)) {
|
||||
let values = unsupportedStyleValues.get(this)
|
||||
if (!value) {
|
||||
values?.delete(property)
|
||||
return
|
||||
}
|
||||
if (!values) {
|
||||
values = new Map()
|
||||
unsupportedStyleValues.set(this, values)
|
||||
}
|
||||
values.set(property, { priority, value })
|
||||
return
|
||||
}
|
||||
nativeSetProperty.call(this, property, value, priority)
|
||||
})
|
||||
vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyValue').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
property,
|
||||
) {
|
||||
if (unsupportedStyleProperties.has(property)) return unsupportedStyleValues.get(this)?.get(property)?.value ?? ''
|
||||
return nativeGetPropertyValue.call(this, property)
|
||||
})
|
||||
vi.spyOn(CSSStyleDeclaration.prototype, 'getPropertyPriority').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
property,
|
||||
) {
|
||||
if (unsupportedStyleProperties.has(property))
|
||||
return unsupportedStyleValues.get(this)?.get(property)?.priority ?? ''
|
||||
return nativeGetPropertyPriority.call(this, property)
|
||||
})
|
||||
vi.spyOn(CSSStyleDeclaration.prototype, 'removeProperty').mockImplementation(function (
|
||||
this: CSSStyleDeclaration,
|
||||
property,
|
||||
) {
|
||||
if (unsupportedStyleProperties.has(property)) {
|
||||
const previous = unsupportedStyleValues.get(this)?.get(property)?.value ?? ''
|
||||
unsupportedStyleValues.get(this)?.delete(property)
|
||||
return previous
|
||||
}
|
||||
return nativeRemoveProperty.call(this, property)
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersect = callback
|
||||
}
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
},
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resize = callback
|
||||
}
|
||||
observe = observe
|
||||
unobserve = unobserve
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: reducedTransparency,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
src = ''
|
||||
decode = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
decodePending.push({ resolve, reject })
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
resetFixture()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
shell?.remove()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function resetFixture(horizontal = true) {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
shell?.remove()
|
||||
|
||||
panelWidth = 480
|
||||
visibility = 'visible'
|
||||
focused = true
|
||||
browser = 'chrome'
|
||||
reducedTransparency = false
|
||||
effectiveSettings.value = {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 48,
|
||||
glassQuality: 'high',
|
||||
glassTransparencyStrength: 48,
|
||||
glassTranslationStrength: 48,
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
document.documentElement.dataset.glassAppearance = effectiveSettings.value.glassAppearance
|
||||
document.documentElement.dataset.glassQuality = effectiveSettings.value.glassQuality
|
||||
|
||||
shell = document.createElement('div')
|
||||
shell.className = `layout-wrapper${horizontal ? ' layout-horizontal-nav-active' : ''}`
|
||||
shell.dataset.shellMode = 'desktop'
|
||||
shell.innerHTML = `
|
||||
<header class="layout-navbar"></header>
|
||||
<aside class="layout-vertical-nav"></aside>
|
||||
<div class="glass-fixed-shell-backplate glass-fixed-shell-backplate--main">
|
||||
<div class="glass-fixed-shell-backplate__layer" data-backplate-slot="current"></div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-grid-content-measure">
|
||||
<div class="v-card" data-card="eligible">
|
||||
<div class="v-card media-card" data-card="media"></div>
|
||||
</div>
|
||||
<div class="v-overlay"><div class="v-card" data-card="overlay"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
document.body.append(shell)
|
||||
card = shell.querySelector('[data-card="eligible"]') as HTMLElement
|
||||
}
|
||||
|
||||
function mountPanel() {
|
||||
wrapper = mount(GlassPanelRefractionDefs, { attachTo: shell })
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function completePendingDecode() {
|
||||
for (const pending of decodePending.splice(0)) pending.resolve()
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
for (let attempt = 0; attempt < 8 && decodePending.length > 0; attempt += 1) {
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function intersectCard(isIntersecting: boolean) {
|
||||
const bounds = card.getBoundingClientRect()
|
||||
intersect?.(
|
||||
[
|
||||
{
|
||||
target: card,
|
||||
isIntersecting,
|
||||
boundingClientRect: bounds,
|
||||
intersectionRatio: isIntersecting ? 1 : 0,
|
||||
intersectionRect: bounds,
|
||||
rootBounds: bounds,
|
||||
time: 0,
|
||||
},
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
function expectNoPanelFilter(element: HTMLElement) {
|
||||
expect(element.style.getPropertyValue('backdrop-filter')).not.toContain('url(')
|
||||
expect(element.style.getPropertyValue('-webkit-backdrop-filter')).not.toContain('url(')
|
||||
expect(element.style.getPropertyValue('--glass-panel-filter')).not.toContain('url(')
|
||||
expect(element.style.getPropertyValue('filter')).not.toContain('url(')
|
||||
}
|
||||
|
||||
it('requires glass desktop Chromium balanced/high visible focus gates', async () => {
|
||||
for (const quality of ['balanced', 'high'] as const) {
|
||||
resetFixture()
|
||||
effectiveSettings.value.glassQuality = quality
|
||||
document.documentElement.dataset.glassQuality = quality
|
||||
vi.clearAllMocks()
|
||||
mountPanel()
|
||||
await settle()
|
||||
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalled()
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ name: 'glass theme', apply: () => (document.documentElement.dataset.theme = 'dark') },
|
||||
{ name: 'desktop shell', apply: () => (shell.dataset.shellMode = 'mobile') },
|
||||
{ name: 'Chromium browser', apply: () => (browser = 'firefox') },
|
||||
{
|
||||
name: 'non-CSS quality',
|
||||
apply: () => {
|
||||
effectiveSettings.value.glassQuality = 'css'
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
},
|
||||
},
|
||||
{ name: 'visible document', apply: () => (visibility = 'hidden') },
|
||||
{ name: 'focused document', apply: () => (focused = false) },
|
||||
{ name: 'reduced transparency', apply: () => (reducedTransparency = true) },
|
||||
]
|
||||
|
||||
for (const { name, apply } of cases) {
|
||||
resetFixture()
|
||||
vi.clearAllMocks()
|
||||
apply()
|
||||
mountPanel()
|
||||
await settle()
|
||||
|
||||
expect(createGlassNavbarDisplacementMap, name).not.toHaveBeenCalled()
|
||||
expect(createGlassPanelBackdropMap, name).not.toHaveBeenCalled()
|
||||
expectNoPanelFilter(card)
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for decode, enhances only top-level content cards, and leaves horizontal navigation untouched', async () => {
|
||||
mountPanel()
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ height: 240, surface: 'panel', width: 480 }),
|
||||
)
|
||||
expectNoPanelFilter(card)
|
||||
|
||||
expect(card.dataset.glassPanelOwner).toMatch(/^glass-panel-/)
|
||||
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important')
|
||||
expect(card.style.getPropertyValue('-webkit-backdrop-filter')).toContain('url(')
|
||||
expectNoPanelFilter(shell.querySelector('[data-card="media"]') as HTMLElement)
|
||||
expectNoPanelFilter(shell.querySelector('[data-card="overlay"]') as HTMLElement)
|
||||
expectNoPanelFilter(shell.querySelector('.layout-navbar') as HTMLElement)
|
||||
expectNoPanelFilter(shell.querySelector('.layout-vertical-nav') as HTMLElement)
|
||||
expect(createGlassPanelBackdropMap).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enhances site and plugin routes without requiring a dashboard', async () => {
|
||||
shell.querySelector('.dashboard-grid')!.className = 'layout-page-content'
|
||||
shell.querySelector('.dashboard-grid-content-measure')!.className = 'plugin-grid'
|
||||
card.classList.add('plugin-card')
|
||||
mountPanel()
|
||||
await settle()
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
expectNoPanelFilter(shell.querySelector('[data-card="media"]') as HTMLElement)
|
||||
expectNoPanelFilter(shell.querySelector('[data-card="overlay"]') as HTMLElement)
|
||||
})
|
||||
|
||||
it('releases far-away filters and reuses decoded geometry when a card returns', async () => {
|
||||
mountPanel()
|
||||
await settle()
|
||||
const calls = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length
|
||||
intersectCard(false)
|
||||
await settle()
|
||||
expectNoPanelFilter(card)
|
||||
expect(wrapper?.findAll('filter')).toHaveLength(0)
|
||||
// 保留背景所有权,屏外表面不能反过来启动第二套静态 WebGL 材质。
|
||||
expect(card.hasAttribute('data-glass-panel-owner')).toBe(true)
|
||||
intersectCard(true)
|
||||
await settle()
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(calls)
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
})
|
||||
|
||||
it.each(['clear', 'tinted'] as const)(
|
||||
'keeps %s free of background diffusion in both enhanced tiers',
|
||||
async appearance => {
|
||||
for (const quality of ['balanced', 'high'] as const) {
|
||||
resetFixture()
|
||||
effectiveSettings.value.glassAppearance = appearance
|
||||
effectiveSettings.value.glassQuality = quality
|
||||
mountPanel()
|
||||
await settle()
|
||||
expect(wrapper?.findAll('feGaussianBlur')).toHaveLength(0)
|
||||
expect(wrapper?.findAll('feDisplacementMap')).toHaveLength(1)
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).not.toContain('blur(')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('restores existing inline declarations and priorities when quality or theme exits', async () => {
|
||||
for (const exit of ['quality', 'theme'] as const) {
|
||||
resetFixture()
|
||||
card.style.setProperty('backdrop-filter', 'blur(2px)', 'important')
|
||||
card.style.setProperty('-webkit-backdrop-filter', 'saturate(80%)')
|
||||
card.dataset.glassPanelRefraction = 'legacy'
|
||||
mountPanel()
|
||||
await settle()
|
||||
|
||||
expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important')
|
||||
expect(card.dataset.glassPanelRefraction).not.toBe('legacy')
|
||||
|
||||
if (exit === 'quality') {
|
||||
effectiveSettings.value.glassQuality = 'css'
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
} else {
|
||||
document.documentElement.dataset.theme = 'dark'
|
||||
}
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
await flushPromises()
|
||||
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toBe('blur(2px)')
|
||||
expect(card.style.getPropertyPriority('backdrop-filter')).toBe('important')
|
||||
expect(card.style.getPropertyValue('-webkit-backdrop-filter')).toBe('saturate(80%)')
|
||||
expect(card.style.getPropertyPriority('-webkit-backdrop-filter')).toBe('')
|
||||
expect(card.dataset.glassPanelRefraction).toBe('legacy')
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).not.toContain('url(')
|
||||
}
|
||||
})
|
||||
|
||||
it('does not bind a late decode after the quality gate is disabled', async () => {
|
||||
mountPanel()
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(decodePending).toHaveLength(1)
|
||||
|
||||
effectiveSettings.value.glassQuality = 'css'
|
||||
document.documentElement.dataset.glassQuality = 'css'
|
||||
await flushPromises()
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
|
||||
expectNoPanelFilter(card)
|
||||
expect(wrapper?.findAll('filter')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops pending work and leaves no filter after unmount', async () => {
|
||||
mountPanel()
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
expect(decodePending).toHaveLength(1)
|
||||
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
completePendingDecode()
|
||||
await flushPromises()
|
||||
|
||||
expectNoPanelFilter(card)
|
||||
expect(card.hasAttribute('data-glass-panel-refraction')).toBe(false)
|
||||
expect(card.hasAttribute('data-glass-panel-owner')).toBe(false)
|
||||
expect(shell.querySelectorAll('filter')).toHaveLength(0)
|
||||
expect(disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let an obsolete decode failure remove the latest material', async () => {
|
||||
mountPanel()
|
||||
await vi.advanceTimersByTimeAsync(65)
|
||||
const obsolete = decodePending.shift()!
|
||||
effectiveSettings.value.glassDeformationStrength = 80
|
||||
await settle()
|
||||
const applied = card.style.getPropertyValue('backdrop-filter')
|
||||
expect(applied).toContain('url(')
|
||||
obsolete.reject(new Error('obsolete image'))
|
||||
await flushPromises()
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toBe(applied)
|
||||
expect(wrapper?.findAll('filter')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rebuilds the card map when observed geometry changes', async () => {
|
||||
mountPanel()
|
||||
await settle()
|
||||
const initialCallCount = vi.mocked(createGlassNavbarDisplacementMap).mock.calls.length
|
||||
|
||||
panelWidth = 620
|
||||
resize?.([], {} as ResizeObserver)
|
||||
expectNoPanelFilter(card)
|
||||
await settle()
|
||||
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(initialCallCount + 1)
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ height: 240, surface: 'panel', width: 620 }),
|
||||
)
|
||||
expect(wrapper?.find('feImage').attributes('width')).toBe('620')
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
})
|
||||
|
||||
it('uses the stable frosted backplate for fixed navigation instead of navigation backdrop filters', async () => {
|
||||
resetFixture(false)
|
||||
effectiveSettings.value.glassAppearance = 'frosted'
|
||||
document.documentElement.dataset.glassAppearance = 'frosted'
|
||||
mountPanel()
|
||||
await settle()
|
||||
|
||||
const layer = shell.querySelector('.glass-fixed-shell-backplate__layer') as HTMLElement
|
||||
const navbar = shell.querySelector('.layout-navbar') as HTMLElement
|
||||
const sidebar = shell.querySelector('.layout-vertical-nav') as HTMLElement
|
||||
|
||||
expect(createGlassPanelBackdropMap).toHaveBeenCalledTimes(1)
|
||||
expect(createGlassPanelBackdropMap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
height: 900,
|
||||
panels: expect.arrayContaining([
|
||||
expect.objectContaining({ height: 64, width: 1200 }),
|
||||
expect.objectContaining({ height: 800, width: 260 }),
|
||||
]),
|
||||
width: 1200,
|
||||
}),
|
||||
)
|
||||
expect(layer.style.getPropertyValue('filter')).toContain('url(')
|
||||
expect(navbar.style.getPropertyValue('--glass-panel-filter')).toBe('')
|
||||
expect(sidebar.style.getPropertyValue('--glass-panel-filter')).toBe('')
|
||||
expect(createGlassNavbarDisplacementMap).toHaveBeenCalledTimes(1)
|
||||
expect(card.style.getPropertyValue('backdrop-filter')).toContain('url(')
|
||||
expect(wrapper?.findAll('feGaussianBlur')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -2049,6 +2049,53 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('hands only native-owned surfaces their static background while preserving shared dynamics', async () => {
|
||||
const three = await import('three')
|
||||
const navbar = appendOpticalSurface('layout-navbar', { height: 64, width: 600, x: 20, y: 20 })
|
||||
const assistant = appendOpticalSurface('agent-assistant-panel', { height: 240, width: 250, x: 700, y: 120 })
|
||||
const root = document.createElement('div')
|
||||
root.className = 'app-wrapper'
|
||||
root.append(navbar, assistant)
|
||||
document.body.append(root)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'fixed',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const getUniforms = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uRectCount: { value: number }
|
||||
uSurfaceBaseWeights: { value: number[] }
|
||||
uSurfaceDynamics: { value: number[] }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
return scene.children[0].material.uniforms
|
||||
}
|
||||
expect(getUniforms().uRectCount.value).toBe(2)
|
||||
expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2)).toEqual([1, 1])
|
||||
navbar.dataset.glassPanelRefraction = 'glass-panel-ready'
|
||||
await vi.waitFor(() => expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2).sort()).toEqual([0, 1]))
|
||||
expect(getUniforms().uSurfaceDynamics.value.slice(0, 2)).toEqual([1, 1])
|
||||
navbar.removeAttribute('data-glass-panel-refraction')
|
||||
await vi.waitFor(() => expect(getUniforms().uSurfaceBaseWeights.value.slice(0, 2)).toEqual([1, 1]))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('shares dynamics across nested hover cards without allocating another material slot', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
@@ -2880,35 +2927,49 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps an initially unfocused visible renderer ready without drawing until focus resumes it', async () => {
|
||||
it('defers all GPU preparation while initially unfocused and uses the latest configuration on focus', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const visibilityState: DocumentVisibilityState = 'visible'
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
documentHasFocus = false
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const compile = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const upload = vi.spyOn(three.WebGLRenderer.prototype, 'initTexture')
|
||||
const appearance = ref<'clear' | 'frosted'>('clear')
|
||||
const quality = ref<'balanced' | 'high'>('balanced')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
appearance,
|
||||
canvas: ref(canvas),
|
||||
quality: ref('balanced'),
|
||||
quality,
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await nextTick()
|
||||
appearance.value = 'frosted'
|
||||
quality.value = 'high'
|
||||
await nextTick()
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
expect(compile).not.toHaveBeenCalled()
|
||||
expect(upload).not.toHaveBeenCalled()
|
||||
|
||||
documentHasFocus = true
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(render).toHaveBeenCalled()
|
||||
expect(compile).toHaveBeenCalled()
|
||||
expect(upload).toHaveBeenCalled()
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as { children: Array<{ material: ShaderMaterial }> }
|
||||
const material = scene.children[0].material
|
||||
expect(material.uniforms.uAppearance.value).toBe(2)
|
||||
expect(material.uniforms.uQuality.value).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
@@ -3572,7 +3633,7 @@ describe('glass optical surface discovery', () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
documentHasFocus = false
|
||||
documentHasFocus = true
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
@@ -3590,6 +3651,7 @@ describe('glass optical surface discovery', () => {
|
||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
vi.useFakeTimers()
|
||||
documentHasFocus = false
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||
@@ -4049,6 +4111,41 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does not draw the main material before its asynchronous compilation completes', async () => {
|
||||
const three = await import('three')
|
||||
let finish: (() => void) | undefined
|
||||
const compile = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce(
|
||||
scene =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finish = () => resolve(scene)
|
||||
}),
|
||||
)
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode: ref('off'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => expect(compile).toHaveBeenCalledOnce())
|
||||
expect(render).not.toHaveBeenCalled()
|
||||
finish?.()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(render).toHaveBeenCalled()
|
||||
} finally {
|
||||
finish?.()
|
||||
scope.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -4060,6 +4157,10 @@ describe('glass optical surface discovery', () => {
|
||||
}),
|
||||
)
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const disposeRenderer = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||
const disposeMaterial = vi.spyOn(three.ShaderMaterial.prototype, 'dispose')
|
||||
const forceContextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
||||
@@ -4083,10 +4184,17 @@ describe('glass optical surface discovery', () => {
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
const baselineRenderCalls = render.mock.calls.length
|
||||
|
||||
scope.stop()
|
||||
expect(forceContextLoss).toHaveBeenCalledOnce()
|
||||
expect(disposeRenderer).not.toHaveBeenCalled()
|
||||
expect(disposeMaterial).not.toHaveBeenCalled()
|
||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
expect(disposeRenderer).toHaveBeenCalledOnce()
|
||||
expect(disposeMaterial).toHaveBeenCalledTimes(2)
|
||||
expect(render).toHaveBeenCalledTimes(baselineRenderCalls)
|
||||
|
||||
expect(ResizeObserverMock.instances).toHaveLength(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Color,
|
||||
IUniform,
|
||||
Mesh,
|
||||
Object3D,
|
||||
OrthographicCamera,
|
||||
Scene,
|
||||
ShaderMaterial,
|
||||
@@ -314,6 +315,8 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
||||
uRects: IUniform<Vector4[]>
|
||||
uSurfaceWeights: IUniform<number[]>
|
||||
uSurfaceDynamics: IUniform<number[]>
|
||||
/** 原生材质已持有背景的表面不重复绘制静态壁纸,但继续消费共享动态场。 */
|
||||
uSurfaceBaseWeights: IUniform<number[]>
|
||||
uPreviousTexture: IUniform<Texture | null>
|
||||
uPreviousFrostedTexture: IUniform<Texture | null>
|
||||
uTexture: IUniform<Texture | null>
|
||||
@@ -344,6 +347,8 @@ interface GlassRendererResources {
|
||||
}
|
||||
|
||||
interface GlassFrostPrefilterResources {
|
||||
/** 编译与释放预滤材质的 WebGL 资源所有者。 */
|
||||
owner: GlassRendererResources
|
||||
material: ShaderMaterial
|
||||
mesh: Mesh
|
||||
scene: Scene
|
||||
@@ -573,6 +578,7 @@ uniform vec4 uRects[8];
|
||||
uniform vec4 uRadii[8];
|
||||
uniform float uSurfaceWeights[8];
|
||||
uniform float uSurfaceDynamics[8];
|
||||
uniform float uSurfaceBaseWeights[8];
|
||||
uniform int uRectCount;
|
||||
uniform float uAppearance;
|
||||
uniform float uBackgroundVisibility;
|
||||
@@ -814,6 +820,7 @@ vec2 softLimitDynamicRefraction(vec2 refraction) {
|
||||
|
||||
void main() {
|
||||
float mask = 0.0;
|
||||
float baseMask = 0.0;
|
||||
float edge = 0.0;
|
||||
float caustic = 0.0;
|
||||
float directionalReflection = 0.0;
|
||||
@@ -869,6 +876,7 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD}
|
||||
|
||||
vec4 rect = uRects[i];
|
||||
float surfaceDynamic = uSurfaceDynamics[i];
|
||||
float surfaceBase = uSurfaceBaseWeights[i];
|
||||
vec2 local = (vUv - rect.xy) / rect.zw;
|
||||
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
|
||||
if (rectMask <= 0.0) continue;
|
||||
@@ -892,18 +900,19 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD}
|
||||
${GLASS_FLUID_FRAGMENT_SURFACE_SHAPE}
|
||||
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
|
||||
${GLASS_FLUID_FRAGMENT_SURFACE_OPTICS}
|
||||
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic;
|
||||
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic * surfaceBase;
|
||||
${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
dynamicRefraction += rippleRefraction * rippleMode * rectMask * surfaceDynamic * interactionMask;
|
||||
edge = max(edge, edgeResponse * rectMask * surfaceDynamic);
|
||||
edge = max(edge, edgeResponse * rectMask * surfaceDynamic * surfaceBase);
|
||||
caustic = max(caustic, localCaustic);
|
||||
caustic = max(
|
||||
caustic,
|
||||
rippleGradientEnergy * rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask
|
||||
);
|
||||
directionalReflection = max(directionalReflection, localDirectionalReflection);
|
||||
topPrism = max(topPrism, localTopPrism);
|
||||
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
|
||||
// 原生表面已有静态轮廓;交互只叠加局部位移与动态焦散,不重新点亮另一套静态棱边。
|
||||
directionalReflection = max(directionalReflection, localDirectionalReflection * surfaceBase);
|
||||
topPrism = max(topPrism, localTopPrism * surfaceBase);
|
||||
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption * surfaceBase);
|
||||
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask);
|
||||
materialEnergy = max(
|
||||
materialEnergy,
|
||||
@@ -915,12 +924,18 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
);
|
||||
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask);
|
||||
mask = max(mask, rectMask);
|
||||
baseMask = max(baseMask, rectMask * uSurfaceBaseWeights[i]);
|
||||
}
|
||||
|
||||
if (mask <= 0.0) discard;
|
||||
|
||||
float contentProtection = getContentProtection(coverUv(vUv + staticRefraction));
|
||||
dynamicRefraction *= contentProtection;
|
||||
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
|
||||
bool dynamicsOnlyOutput = uDynamicsOnly > 0.5 || baseMask <= 0.0;
|
||||
// 原生材质持有静态背景;无动态能量的像素不重复采样或输出另一套轮廓。
|
||||
if (dynamicsOnlyOutput && dynamicsPresence <= 0.0) discard;
|
||||
if (dot(dynamicRefraction, dynamicRefraction) > 0.0) {
|
||||
dynamicRefraction *= getContentProtection(coverUv(vUv + staticRefraction));
|
||||
}
|
||||
// 高光足迹与壁纸位移强度独立校准,收紧反馈范围不能同步削弱三项动态参数。
|
||||
dynamicRefraction *= 1.2;
|
||||
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
||||
@@ -1010,6 +1025,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
caustic * proceduralCausticAlpha
|
||||
) *
|
||||
uReflectionStrength;
|
||||
if (dynamicsOnlyOutput) proceduralAlpha *= clamp(dynamicsPresence, 0.0, 1.0);
|
||||
gl_FragColor = vec4(proceduralHighlight, proceduralAlpha);
|
||||
return;
|
||||
}
|
||||
@@ -1028,8 +1044,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
refracted = mix(refracted, highlight, reflectionMix);
|
||||
refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget;
|
||||
|
||||
if (uDynamicsOnly > 0.5) {
|
||||
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
|
||||
if (dynamicsOnlyOutput) {
|
||||
float dynamicsAlpha =
|
||||
clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82);
|
||||
gl_FragColor = vec4(refracted, dynamicsAlpha);
|
||||
@@ -1039,7 +1054,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||
gl_FragColor = vec4(
|
||||
refracted,
|
||||
clamp(
|
||||
mask *
|
||||
baseMask *
|
||||
(
|
||||
materialAlpha +
|
||||
(
|
||||
@@ -1240,6 +1255,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const failedWallpaperPreparationKey = ref('')
|
||||
let three: ThreeModule | null = null
|
||||
let resources: GlassRendererResources | null = null
|
||||
const pendingCompilations = new WeakMap<GlassRendererResources, Set<Promise<unknown>>>()
|
||||
const retiredResources = new WeakSet<GlassRendererResources>()
|
||||
const compiledMainScenes = new WeakSet<GlassRendererResources>()
|
||||
let fluidDynamics: GlassFluidDynamics | null = null
|
||||
let rippleResources: GlassRippleDynamics | null = null
|
||||
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
|
||||
@@ -1333,6 +1351,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let resumeVersion = 0
|
||||
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
|
||||
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
|
||||
let preparationDeferred = false
|
||||
let dynamicsGeneration = 0
|
||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||
const usesDynamicsOnly = () =>
|
||||
@@ -1344,6 +1363,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
|
||||
}
|
||||
|
||||
/** 后台只保留最新配置;恢复时合并重建,避免准备完的纹理和波场立即被释放。 */
|
||||
function canPrepareResources() {
|
||||
if (canPresentFrame()) return true
|
||||
preparationDeferred = true
|
||||
return false
|
||||
}
|
||||
|
||||
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
||||
function syncWallpaperSamplingMode() {
|
||||
if (!resources) return
|
||||
@@ -1622,12 +1648,34 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
||||
}
|
||||
|
||||
/** Three 会在异步检查中读取材质的 program;该检查完成前不能释放其 properties。 */
|
||||
async function compileOwnedScene(owner: GlassRendererResources, scene: Object3D) {
|
||||
if (retiredResources.has(owner)) throw new Error('Glass renderer was retired before compilation')
|
||||
const pending = owner.renderer.compileAsync(scene, owner.camera)
|
||||
const work = pendingCompilations.get(owner) ?? new Set<Promise<unknown>>()
|
||||
work.add(pending)
|
||||
pendingCompilations.set(owner, work)
|
||||
try {
|
||||
await pending
|
||||
if (scene === owner.scene && !retiredResources.has(owner)) compiledMainScenes.add(owner)
|
||||
} finally {
|
||||
work.delete(pending)
|
||||
}
|
||||
}
|
||||
|
||||
function disposeAfterCompilation(owner: GlassRendererResources, dispose: () => void) {
|
||||
const pending = pendingCompilations.get(owner)
|
||||
if (pending?.size) void Promise.allSettled([...pending]).then(dispose)
|
||||
else dispose()
|
||||
}
|
||||
|
||||
/** 释放壁纸准备阶段复用的预滤 shader;活动低通纹理由各自 RenderTarget 单独持有。 */
|
||||
function disposeFrostPrefilterResources() {
|
||||
if (!frostPrefilterResources) return
|
||||
|
||||
frostPrefilterResources.material.dispose()
|
||||
const retired = frostPrefilterResources
|
||||
frostPrefilterResources = null
|
||||
disposeAfterCompilation(retired.owner, () => retired.material.dispose())
|
||||
}
|
||||
|
||||
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
|
||||
@@ -1651,13 +1699,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const mesh = new three.Mesh(resources.geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
frostPrefilterResources = { material, mesh, scene, uniforms }
|
||||
frostPrefilterResources = { owner: resources, material, mesh, scene, uniforms }
|
||||
|
||||
return frostPrefilterResources
|
||||
}
|
||||
|
||||
/** 壁纸上传时执行两次 separable blur,常态只保留既定分辨率的低通 RenderTarget。 */
|
||||
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number, targetLongEdge: number) {
|
||||
if (!canPrepareResources()) return null
|
||||
if (!resources || !three) return null
|
||||
|
||||
const ownerResources = resources
|
||||
@@ -1681,8 +1730,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
try {
|
||||
ownerResources.renderer.initTexture(texture)
|
||||
await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera)
|
||||
if (resources !== ownerResources) {
|
||||
await compileOwnedScene(ownerResources, prefilter.scene)
|
||||
if (resources !== ownerResources || !canPrepareResources()) {
|
||||
outputTarget.dispose()
|
||||
return null
|
||||
}
|
||||
@@ -1759,6 +1808,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** 只在水漾被选中时为当前 context 编译并分配独占 ping-pong 场。 */
|
||||
async function syncRippleResources() {
|
||||
if (!resources || !three) return
|
||||
if (!canPrepareResources()) return
|
||||
if (!hasRippleCapability()) {
|
||||
disposeRippleResources()
|
||||
return
|
||||
@@ -1778,9 +1828,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
three,
|
||||
viewportHeight: window.innerHeight,
|
||||
viewportWidth: window.innerWidth,
|
||||
compile: scene => compileOwnedScene(ownerResources, scene),
|
||||
isCurrent: () =>
|
||||
generation === dynamicsGeneration &&
|
||||
resources === ownerResources &&
|
||||
hasRippleCapability() &&
|
||||
canPrepareResources(),
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) return
|
||||
if (
|
||||
generation !== dynamicsGeneration ||
|
||||
resources !== ownerResources ||
|
||||
!hasRippleCapability() ||
|
||||
!canPrepareResources()
|
||||
)
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) {
|
||||
@@ -1801,6 +1863,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** 模式切换时同步释放旧策略、清空输入历史并在资源就绪后恢复订阅。 */
|
||||
async function syncDynamicsMode() {
|
||||
if (!resources) return
|
||||
if (!canPrepareResources()) return
|
||||
|
||||
interactionAnimating = false
|
||||
activeTouchIdentifier = null
|
||||
@@ -1845,7 +1908,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
|
||||
if (!resources || !canPresentFrame()) return
|
||||
// 首次 resize/observer 只能同步几何,不能在异步预编译前触发同步材质编译。
|
||||
if (!resources || !canPresentFrame() || !compiledMainScenes.has(resources)) return
|
||||
|
||||
updateWallpaperTransition(timestamp)
|
||||
if (fluidDynamics && advanceFlow) {
|
||||
@@ -1923,6 +1987,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const uniformRadii = resources.uniforms.uRadii.value
|
||||
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
||||
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
||||
const uniformBaseWeights = resources.uniforms.uSurfaceBaseWeights.value
|
||||
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
|
||||
const transitionWeights = outgoingSurface
|
||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||
@@ -1952,6 +2017,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const nestedInteractionAvailable =
|
||||
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
|
||||
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
|
||||
uniformBaseWeights[index] =
|
||||
slot &&
|
||||
!slot.key.hasAttribute('data-glass-panel-owner') &&
|
||||
!slot.key.hasAttribute('data-glass-panel-refraction')
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
|
||||
resources.uniforms.uRectCount.value = normalized.length
|
||||
@@ -3108,8 +3179,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
await nextTick()
|
||||
if (version !== resumeVersion || !canResume()) return
|
||||
if (!resources) {
|
||||
await initializeRenderer()
|
||||
if (!resources || preparationDeferred) {
|
||||
await initializeRenderer(false)
|
||||
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
return
|
||||
}
|
||||
@@ -3126,6 +3197,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
animationFrame = requestAnimationFrame(renderInteractionFrame)
|
||||
}
|
||||
scheduleWallpaperTransition()
|
||||
preparePendingWallpaper()
|
||||
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||
})()
|
||||
|
||||
@@ -3283,7 +3355,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
observedMutationRoots.add(root)
|
||||
surfaceMutationObserver?.observe(root, {
|
||||
attributeFilter: ['data-glass-optical-boundary', 'data-glass-optical-mode'],
|
||||
attributeFilter: [
|
||||
'data-glass-optical-boundary',
|
||||
'data-glass-optical-mode',
|
||||
'data-glass-panel-refraction',
|
||||
'data-glass-panel-owner',
|
||||
],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree,
|
||||
@@ -3293,6 +3370,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
surfaceMutationObserver = new MutationObserver(mutations => {
|
||||
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
||||
if (
|
||||
mutations.some(
|
||||
mutation =>
|
||||
mutation.attributeName === 'data-glass-panel-owner' &&
|
||||
mutation.target instanceof Element &&
|
||||
mutation.target.hasAttribute('data-glass-panel-owner'),
|
||||
)
|
||||
) {
|
||||
// 背景所有权交接无需等待几何稳定采样,避免同一帧出现两套静态材质。
|
||||
writeSurfaceUniforms()
|
||||
scheduleFrame()
|
||||
}
|
||||
if (!mutationTouchesOpticalSurface(mutations)) return
|
||||
|
||||
interactionClipMembershipDirty = true
|
||||
@@ -3388,6 +3477,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
function disposeRenderer(releaseContext = true) {
|
||||
if (resources) retiredResources.add(resources)
|
||||
resumeVersion += 1
|
||||
loadVersion += 1
|
||||
prepareVersion += 1
|
||||
@@ -3458,11 +3548,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
disposeRippleResources()
|
||||
disposeFrostPrefilterResources()
|
||||
if (resources) {
|
||||
resources.geometry.dispose()
|
||||
resources.material.dispose()
|
||||
resources.renderer.dispose()
|
||||
if (releaseContext) resources.renderer.forceContextLoss()
|
||||
const retired = resources
|
||||
resources = null
|
||||
// KHR 在 context loss 后把编译状态报告为完成,原生轮询可正常退出,再安全释放缓存。
|
||||
if (releaseContext) retired.renderer.forceContextLoss()
|
||||
disposeAfterCompilation(retired, () => {
|
||||
retired.geometry.dispose()
|
||||
retired.material.dispose()
|
||||
retired.renderer.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
delete document.documentElement.dataset.glassRendererState
|
||||
@@ -3479,6 +3573,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */
|
||||
async function refreshWallpaper(message: string, beforeActivate?: () => void) {
|
||||
if (!canPrepareResources()) return
|
||||
const version = ++loadVersion
|
||||
const retainsActiveTexture = Boolean(resources && activeTexture)
|
||||
if (!retainsActiveTexture) updateRendererState('loading')
|
||||
@@ -3696,6 +3791,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
releasePreparedWallpaper()
|
||||
clearPreparedWallpaperFailure()
|
||||
if (!url || !resources || contextRecoveryPending || url === toValue(options.wallpaperUrl)) return
|
||||
if (!canPrepareResources()) return
|
||||
const preparationKey = getWallpaperPreparationKey(url)
|
||||
|
||||
try {
|
||||
@@ -3706,7 +3802,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
!resources ||
|
||||
contextRecoveryPending ||
|
||||
preparationKey !== prepared.preparationKey ||
|
||||
preparationKey !== getWallpaperPreparationKey(url)
|
||||
preparationKey !== getWallpaperPreparationKey(url) ||
|
||||
!canPrepareResources()
|
||||
) {
|
||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||
return
|
||||
@@ -3714,7 +3811,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
resources.renderer.initTexture(prepared.texture)
|
||||
recordGlassRendererTiming(presentationSpace, 'prepare-compile-start')
|
||||
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
||||
await compileOwnedScene(resources, resources.scene)
|
||||
recordGlassRendererTiming(presentationSpace, 'prepare-compile-ready')
|
||||
if (
|
||||
version !== prepareVersion ||
|
||||
@@ -3742,6 +3839,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
async function loadWallpaper(url: string, version: number, beforeActivate?: () => void) {
|
||||
if (!resources || !three || !url) return
|
||||
if (!canPrepareResources()) return
|
||||
|
||||
const prepared = await createWallpaperTexture(url)
|
||||
if (!prepared) return
|
||||
@@ -3749,20 +3847,22 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
version !== loadVersion ||
|
||||
!resources ||
|
||||
contextRecoveryPending ||
|
||||
prepared.preparationKey !== getWallpaperPreparationKey(url)
|
||||
prepared.preparationKey !== getWallpaperPreparationKey(url) ||
|
||||
!canPrepareResources()
|
||||
) {
|
||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||
return
|
||||
}
|
||||
|
||||
recordGlassRendererTiming(presentationSpace, 'compile-start')
|
||||
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
||||
await compileOwnedScene(resources, resources.scene)
|
||||
recordGlassRendererTiming(presentationSpace, 'compile-ready')
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
!resources ||
|
||||
contextRecoveryPending ||
|
||||
prepared.preparationKey !== getWallpaperPreparationKey(url)
|
||||
prepared.preparationKey !== getWallpaperPreparationKey(url) ||
|
||||
!canPrepareResources()
|
||||
) {
|
||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||
return
|
||||
@@ -3901,6 +4001,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
async function initializeRenderer(releaseContext = true) {
|
||||
if (!canPrepareResources()) return
|
||||
preparationDeferred = false
|
||||
recordGlassRendererTiming(presentationSpace, 'initialize-start')
|
||||
disposeRenderer(releaseContext)
|
||||
if (!toValue(options.active) || !options.canvas.value) return
|
||||
@@ -3916,7 +4018,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
try {
|
||||
three = await import('three')
|
||||
recordGlassRendererTiming(presentationSpace, 'three-ready')
|
||||
if (version !== loadVersion || !options.canvas.value) return
|
||||
if (version !== loadVersion || !options.canvas.value || !canPrepareResources()) return
|
||||
const Vector4Class = three.Vector4
|
||||
const canvas = options.canvas.value
|
||||
const context = prepareGlassWebGLContext(canvas)
|
||||
@@ -3974,6 +4076,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
||||
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
||||
uSurfaceBaseWeights: { value: Array.from({ length: 8 }, () => 1) },
|
||||
uPreviousTexture: { value: null },
|
||||
uPreviousFrostedTexture: { value: null },
|
||||
uTexture: { value: null },
|
||||
@@ -4018,7 +4121,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
version !== loadVersion ||
|
||||
resources !== ownerResources ||
|
||||
!toValue(options.active) ||
|
||||
options.canvas.value !== canvas
|
||||
options.canvas.value !== canvas ||
|
||||
!canPrepareResources()
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -4115,6 +4219,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
() => toValue(options.appearance),
|
||||
async (appearance, previousAppearance) => {
|
||||
if (!resources) return
|
||||
if (!canPrepareResources()) return
|
||||
|
||||
const applyAppearance = () => {
|
||||
if (!resources || toValue(options.appearance) !== appearance) return
|
||||
@@ -4170,6 +4275,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
() => toValue(options.quality),
|
||||
async (quality, previousQuality) => {
|
||||
if (!resources) return
|
||||
if (!canPrepareResources()) return
|
||||
|
||||
const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey))
|
||||
const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey))
|
||||
|
||||
@@ -153,7 +153,7 @@ function createRippleHarness(
|
||||
} as unknown as typeof import('three')
|
||||
|
||||
return {
|
||||
create: () =>
|
||||
create: (overrides: Partial<Parameters<typeof createGlassRippleDynamics>[0]> = {}) =>
|
||||
createGlassRippleDynamics({
|
||||
camera: {} as never,
|
||||
geometry: {} as never,
|
||||
@@ -162,6 +162,7 @@ function createRippleHarness(
|
||||
three,
|
||||
viewportHeight: 800,
|
||||
viewportWidth: 1200,
|
||||
...overrides,
|
||||
}),
|
||||
renderer,
|
||||
snapshots,
|
||||
@@ -174,6 +175,50 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('glass ripple dynamics', () => {
|
||||
it('waits for owner compilation before rendering the initial neutral field', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
const compile = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
const harness = createRippleHarness()
|
||||
const creation = harness.create({ compile, isCurrent: () => true })
|
||||
|
||||
expect(compile).toHaveBeenCalledOnce()
|
||||
expect(harness.renderer.render).not.toHaveBeenCalled()
|
||||
|
||||
finish?.()
|
||||
const dynamics = await creation
|
||||
|
||||
expect(harness.renderer.render).toHaveBeenCalledTimes(2)
|
||||
expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('retains pending material until compilation finishes and skips superseded initialization', async () => {
|
||||
let finish: (() => void) | undefined
|
||||
let current = true
|
||||
const compile = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
const harness = createRippleHarness()
|
||||
const creation = harness.create({ compile, isCurrent: () => current })
|
||||
const result = expect(creation).rejects.toThrow('superseded')
|
||||
const initialRenders = harness.renderer.render.mock.calls.length
|
||||
current = false
|
||||
expect(FakeShaderMaterial.instances[0].dispose).not.toHaveBeenCalled()
|
||||
finish?.()
|
||||
await result
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.renderer.render).toHaveBeenCalledTimes(initialRenders)
|
||||
expect(harness.renderer.compileAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses one bounded half-float ping-pong field when the renderer supports it', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BufferGeometry,
|
||||
IUniform,
|
||||
Object3D,
|
||||
OrthographicCamera,
|
||||
Texture,
|
||||
Vector2,
|
||||
@@ -71,6 +72,10 @@ interface CreateGlassRippleDynamicsOptions {
|
||||
three: ThreeModule
|
||||
viewportHeight: number
|
||||
viewportWidth: number
|
||||
/** 由 owner 跟踪的异步编译,确保编译检查结束前资源保持有效。 */
|
||||
compile?: (scene: Object3D) => Promise<unknown>
|
||||
/** 代次失效后不再初始化或呈现波场。 */
|
||||
isCurrent?: () => boolean
|
||||
}
|
||||
|
||||
const RIPPLE_VERTEX_SHADER = `
|
||||
@@ -233,6 +238,8 @@ export async function createGlassRippleDynamics(
|
||||
let clearOnNextFrame = false
|
||||
let fieldActive = false
|
||||
let disposed = false
|
||||
// 初次尺寸准备只能更新 target 和 uniform;GPU 中性场必须等 owner 编译完成后再写入。
|
||||
let compilationSettled = false
|
||||
const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType
|
||||
|
||||
const createTarget = () => {
|
||||
@@ -332,7 +339,7 @@ export async function createGlassRippleDynamics(
|
||||
}
|
||||
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
|
||||
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
||||
reset()
|
||||
if (compilationSettled) reset()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -365,10 +372,12 @@ export async function createGlassRippleDynamics(
|
||||
}
|
||||
|
||||
try {
|
||||
const initializedByResize = resize(viewportWidth, viewportHeight)
|
||||
await renderer.compileAsync(scene, camera)
|
||||
if (disposed) throw new Error('Ripple resources were disposed during compilation')
|
||||
if (!initializedByResize) reset()
|
||||
resize(viewportWidth, viewportHeight)
|
||||
await (options.compile ? options.compile(scene) : renderer.compileAsync(scene, camera))
|
||||
if (disposed || options.isCurrent?.() === false)
|
||||
throw new Error('Ripple resources were superseded during compilation')
|
||||
compilationSettled = true
|
||||
reset()
|
||||
} catch (error) {
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
|
||||
@@ -49,8 +49,9 @@ describe('glass overlay material styles', () => {
|
||||
it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
|
||||
expect(styles.match(/--glass-overlay-blur:\s*var\(--glass-overlay-clarity-blur, 6px\)/g)).toHaveLength(2)
|
||||
expect(styles).toContain('calc(0.58 + var(--glass-surface-density, 0.62) * 0.18)')
|
||||
expect(styles).toContain('calc(0.58 + var(--glass-surface-density, 0.72) * 0.18)')
|
||||
expect(styles.match(/--glass-overlay-blur:\s*0px/g)).toHaveLength(2)
|
||||
expect(styles).toContain('--glass-overlay-saturate: 115%')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
||||
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
||||
@@ -282,10 +283,10 @@ describe('glass overlay material styles', () => {
|
||||
/\.layout-vertical-nav\s*\{[\s\S]*?&::before\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-sidebar-live-filter\);[\s\S]*?background-image:\s*var\(--glass-sheen\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/,
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\):not\(\[data-glass-panel-refraction\]\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-high'\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/,
|
||||
/\.layout-wrapper\[data-glass-navigation-refraction='chromium'\]\[data-glass-sidebar-refraction-ready='true'\][\s\S]*?\.layout-vertical-nav:not\(\.overlay-nav\):not\(\[data-glass-panel-refraction\]\)\s*\{[\s\S]*?url\('#glass-sidebar-live-refraction-balanced'\)/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/&\[data-glass-appearance='frosted'\]\s*\{[\s\S]*?\.layout-vertical-nav::before\s*\{[\s\S]*?var\(--glass-sidebar-absorption-start\)[\s\S]*?var\(--glass-sidebar-absorption-end\)[\s\S]*?var\(--glass-sidebar-edge-opacity\)/,
|
||||
@@ -343,7 +344,7 @@ describe('glass overlay material styles', () => {
|
||||
it('shares the same light frost when glass navbars overlap scrolled content', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%)')
|
||||
expect(styles).toContain('--glass-navbar-scrolled-backdrop-filter: saturate(115%)')
|
||||
expect(styles).toMatch(
|
||||
/:is\(\[data-glass-appearance='clear'\], \[data-glass-appearance='tinted'\]\)[\s\S]*?\.layout-wrapper\.window-scrolled\.layout-navbar-fixed \.layout-navbar,[\s\S]*?backdrop-filter:\s*var\(--glass-navbar-scrolled-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
@@ -504,9 +505,7 @@ describe('glass overlay material styles', () => {
|
||||
)
|
||||
expect(baseMaterialRule).toContain('var(--glass-background-visibility, 0.58)')
|
||||
expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-blur: clamp(')
|
||||
expect(baseMaterialRule).toContain('0.62px + var(--glass-surface-density, 0.62) * 0.8px')
|
||||
expect(baseMaterialRule).toContain('- var(--glass-background-visibility, 0.58) * 0.25px')
|
||||
expect(baseMaterialRule).not.toContain('blur(')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(')
|
||||
@@ -515,7 +514,7 @@ describe('glass overlay material styles', () => {
|
||||
expect(baseMaterialRule).toContain(
|
||||
'--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18)',
|
||||
)
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: blur(var(--glass-navbar-blur))')
|
||||
expect(baseMaterialRule).toContain('--glass-navbar-live-filter: saturate(var(--glass-navbar-saturation))')
|
||||
expect(baseMaterialRule).toContain('brightness(var(--glass-navbar-brightness))')
|
||||
expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important')
|
||||
expect(baseMaterialRule).toContain('box-shadow: var(--glass-navbar-shadow) !important')
|
||||
@@ -529,7 +528,7 @@ describe('glass overlay material styles', () => {
|
||||
expect(svgFilterRule).toContain("data-glass-navbar-refraction-ready='true'")
|
||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-high')")
|
||||
expect(svgFilterRule).toContain('blur(var(--glass-navbar-blur))')
|
||||
expect(svgFilterRule).not.toContain('blur(')
|
||||
expect(svgFilterRule).toContain('saturate(var(--glass-navbar-saturation))')
|
||||
expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))')
|
||||
expect(svgFilterRule).not.toContain('background:')
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@mixin surfaces {
|
||||
html[data-theme='glass'] {
|
||||
--glass-v3-ink: 23, 27, 32;
|
||||
--glass-v3-fill: clamp(0.1, calc(0.08 + var(--glass-surface-density, 0.62) * 0.12), 0.22);
|
||||
--glass-v3-fill: clamp(0.12, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2), 0.32);
|
||||
--glass-v3-rim: clamp(0.12, calc(0.16 + var(--glass-reflection, 0.38) * 0.28), 0.4);
|
||||
--glass-v3-sheen: clamp(0.04, calc(0.035 + var(--glass-reflection, 0.38) * 0.18), 0.2);
|
||||
--glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
||||
@@ -14,13 +14,7 @@
|
||||
--glass-v3-navigation-inset: 8px;
|
||||
--glass-v3-navigation-content-gap: 16px;
|
||||
--glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
--glass-v3-navigation-blur: clamp(
|
||||
0.65px,
|
||||
calc(0.9px + var(--glass-surface-density, 0.62) * 0.6px - var(--glass-background-visibility, 0.58) * 0.25px),
|
||||
1.6px
|
||||
);
|
||||
--glass-v3-navigation-filter: blur(var(--glass-v3-navigation-blur)) saturate(118%)
|
||||
brightness(var(--glass-transmission-brightness, 1));
|
||||
--glass-v3-navigation-filter: saturate(118%) brightness(var(--glass-transmission-brightness, 1));
|
||||
--glass-v3-card-background:
|
||||
linear-gradient(128deg, rgba(255, 255, 255, var(--glass-v3-sheen)), transparent 38%),
|
||||
linear-gradient(
|
||||
@@ -210,6 +204,36 @@
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.layout-navbar[data-glass-panel-refraction] {
|
||||
background: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
box-shadow: var(--glass-v3-navigation-shadow) !important;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
content: '';
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: var(--glass-v3-card-background);
|
||||
box-shadow: var(--glass-v3-surface-edge);
|
||||
}
|
||||
}
|
||||
|
||||
// 外投影与背景采样分属两层,前景导航不进入滤镜,也不让投影扩张采样边界。
|
||||
.layout-vertical-nav[data-glass-panel-refraction] {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.layout-vertical-nav[data-glass-panel-refraction]::before,
|
||||
.layout-navbar[data-glass-panel-refraction]::before {
|
||||
backdrop-filter: var(--glass-panel-filter) !important;
|
||||
-webkit-backdrop-filter: var(--glass-panel-filter) !important;
|
||||
}
|
||||
|
||||
// 标签栏由内容层另行预留;这里只补主导航内缩和下方间距,避免标签高度重复占位。
|
||||
.layout-page-content {
|
||||
padding-block-start: calc(
|
||||
@@ -249,7 +273,7 @@
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) {
|
||||
.layout-navbar {
|
||||
.layout-navbar:not([data-glass-panel-refraction]) {
|
||||
--glass-navbar-live-filter: var(--glass-v3-navigation-filter);
|
||||
|
||||
transform: none !important;
|
||||
@@ -299,6 +323,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 清透材质以单条原生轮廓承接真实折射,不让多组柔光在圆角处形成分离的内外弧线。
|
||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||
body[data-theme='glass'] {
|
||||
.v-card {
|
||||
--glass-v3-surface-edge: inset 0 1px 0 var(--glass-highlight);
|
||||
}
|
||||
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) .layout-navbar,
|
||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) .layout-vertical-nav::before {
|
||||
--glass-v3-surface-edge: inset 0 0 0 1px var(--glass-border-raised);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.v-card:hover {
|
||||
--glass-v3-surface-edge: inset 0 1px 0 var(--glass-border-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
html[data-theme='glass'] {
|
||||
--glass-v3-navigation-filter: none !important;
|
||||
|
||||
@@ -53,11 +53,12 @@ html[data-theme='glass'] {
|
||||
--glass-control-backdrop-filter: none;
|
||||
--glass-control-prominent-backdrop-filter: none;
|
||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
||||
--glass-navbar-scrolled-backdrop-filter: saturate(115%);
|
||||
--glass-navbar-live-filter: none;
|
||||
--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter);
|
||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||
// 清透弹层不散射背景,靠独立吸收层区分前后两组文字,不改变内容卡片的通透度。
|
||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.58 + var(--glass-surface-density, 0.62) * 0.18));
|
||||
--glass-overlay-blur: 0px;
|
||||
--glass-overlay-saturate: 115%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
||||
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
||||
@@ -67,14 +68,10 @@ html[data-theme='glass'] {
|
||||
--glass-control-shortcut-border: rgba(255, 255, 255, 12%);
|
||||
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
|
||||
// Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。
|
||||
--glass-chip-backdrop-filter: blur(8px) saturate(150%) brightness(var(--glass-transmission-brightness));
|
||||
--glass-chip-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 12%),
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 3%) 72%,
|
||||
transparent
|
||||
);
|
||||
--glass-chip-backdrop-filter: saturate(150%) brightness(var(--glass-transmission-brightness));
|
||||
--glass-chip-sheen:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 12%), transparent 38%, rgba(255, 255, 255, 3%) 72%, transparent),
|
||||
linear-gradient(rgba(11, 19, 34, 36%), rgba(11, 19, 34, 36%));
|
||||
--glass-chip-tint-opacity: calc(0.22 + var(--glass-tint-density, 0.65) * 0.18);
|
||||
--glass-button-surface: rgba(255, 255, 255, 8%);
|
||||
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
|
||||
@@ -187,13 +184,13 @@ html[data-theme='glass'] {
|
||||
);
|
||||
--glass-overlay-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
||||
rgba(11, 19, 34, calc(0.58 + var(--glass-surface-density, 0.72) * 0.18)) 88%,
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.22))
|
||||
);
|
||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||
--glass-overlay-blur: 0px;
|
||||
--glass-overlay-saturate: 120%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
||||
--glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness));
|
||||
--glass-chip-backdrop-filter: saturate(165%) brightness(var(--glass-transmission-brightness));
|
||||
}
|
||||
|
||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||
@@ -1774,11 +1771,6 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
|
||||
calc(0.045 + var(--glass-surface-density, 0.62) * 0.1 + (1 - var(--glass-background-visibility, 0.58)) * 0.05),
|
||||
0.28
|
||||
);
|
||||
--glass-navbar-blur: clamp(
|
||||
0.35px,
|
||||
calc(0.62px + var(--glass-surface-density, 0.62) * 0.8px - var(--glass-background-visibility, 0.58) * 0.25px),
|
||||
1.4px
|
||||
);
|
||||
--glass-navbar-brightness: var(--glass-transmission-brightness, 1);
|
||||
--glass-navbar-saturation: clamp(
|
||||
110%,
|
||||
@@ -1801,8 +1793,7 @@ html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appeara
|
||||
inset 0 -1px 2px rgba(4, 10, 20, calc(0.1 + var(--glass-surface-density, 0.62) * 0.12)),
|
||||
0 12px 32px
|
||||
rgba(3, 7, 18, calc(0.08 + var(--glass-reflection, 0.5) * 0.1 + var(--glass-surface-density, 0.62) * 0.08));
|
||||
--glass-navbar-live-filter: blur(var(--glass-navbar-blur)) saturate(var(--glass-navbar-saturation))
|
||||
brightness(var(--glass-navbar-brightness));
|
||||
--glass-navbar-live-filter: saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||
|
||||
border: 0 !important;
|
||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !important;
|
||||
@@ -1821,8 +1812,8 @@ html[data-theme='glass'][data-glass-quality='high']:is(
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(var(--glass-navbar-blur))
|
||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') saturate(var(--glass-navbar-saturation))
|
||||
brightness(var(--glass-navbar-brightness));
|
||||
}
|
||||
|
||||
html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||
@@ -1832,8 +1823,8 @@ html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||
.layout-navbar {
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(var(--glass-navbar-blur))
|
||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') saturate(var(--glass-navbar-saturation))
|
||||
brightness(var(--glass-navbar-brightness));
|
||||
}
|
||||
|
||||
// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。
|
||||
@@ -1843,8 +1834,8 @@ html[data-theme='glass'][data-glass-quality='high']:is(
|
||||
)
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') blur(1px) saturate(115%)
|
||||
.layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') saturate(115%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
@@ -1862,8 +1853,8 @@ html[data-theme='glass'][data-glass-quality='balanced']:is(
|
||||
)
|
||||
body[data-theme='glass']
|
||||
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') blur(1px) saturate(115%)
|
||||
.layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) {
|
||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') saturate(115%)
|
||||
brightness(var(--glass-transmission-brightness));
|
||||
|
||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createGlassNavbarDisplacementField,
|
||||
createGlassPanelBackdropField,
|
||||
getGlassNavbarOpticalResponse,
|
||||
getGlassSidebarOpticalResponse,
|
||||
supportsGlassNavbarLiveRefraction,
|
||||
@@ -81,6 +82,34 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(128)
|
||||
})
|
||||
|
||||
it('encodes a monotonic diffusion mask without changing the panel displacement channels', () => {
|
||||
const field = createGlassNavbarDisplacementField({ width: 400, height: 240, radius: 20, surface: 'panel' })
|
||||
const weights = Array.from({ length: 24 }, (_, y) => pixelAt(field, 200, y)[1])
|
||||
expect(weights[0]).toBe(0)
|
||||
expect(weights[weights.length - 1]).toBe(255)
|
||||
expect(weights.every((value, index) => index === 0 || value >= weights[index - 1])).toBe(true)
|
||||
expect(pixelAt(field, 0, 0)[1]).toBe(0)
|
||||
expect(pixelAt(field, 200, 120)[1]).toBe(255)
|
||||
})
|
||||
|
||||
it('places backdrop contours in their real coordinates without overwriting rounded gaps', () => {
|
||||
const field = createGlassPanelBackdropField({
|
||||
width: 100,
|
||||
height: 80,
|
||||
optics: getGlassSidebarOpticalResponse({ deformation: 0, translation: 0 }),
|
||||
panels: [
|
||||
{ x: 10, y: 10, width: 70, height: 60, radius: 10 },
|
||||
{ x: 30, y: 15, width: 50, height: 40, radius: 10 },
|
||||
],
|
||||
})
|
||||
expect(pixelAt(field, 5, 5)).toEqual([128, 255, 128, 255])
|
||||
expect(pixelAt(field, 10, 10)[1]).toBe(255)
|
||||
expect(pixelAt(field, 25, 10)[1]).toBe(0)
|
||||
expect(pixelAt(field, 35, 15)[1]).toBe(255)
|
||||
expect(pixelAt(field, 45, 15)[1]).toBe(0)
|
||||
expect(pixelAt(field, 50, 40)[1]).toBe(255)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ width: 1423, height: 64, radius: 16 },
|
||||
{ width: 401, height: 72, radius: 16 },
|
||||
@@ -97,6 +126,10 @@ describe('createGlassNavbarDisplacementField', () => {
|
||||
...[60, 252].flatMap(width =>
|
||||
[0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })),
|
||||
),
|
||||
{ width: 1163, height: 448, radius: 20, surface: 'panel' as const },
|
||||
{ width: 358, height: 300, radius: 20, surface: 'panel' as const },
|
||||
{ width: 140, height: 120, radius: 8, surface: 'panel' as const },
|
||||
{ width: 140, height: 120, radius: 32, surface: 'panel' as const },
|
||||
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||
for (const deformation of [0, 48, 100])
|
||||
for (const translation of [0, 48, 100])
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface GlassNavbarRefractionBrowserIdentity {
|
||||
}
|
||||
|
||||
export interface GlassNavbarDisplacementGeometry {
|
||||
/** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */
|
||||
surface?: 'navbar' | 'sidebar'
|
||||
/** 顶栏保护字形高度;侧栏使用窄边透镜;大面板沿长边展开光学过渡。 */
|
||||
surface?: 'navbar' | 'sidebar' | 'panel'
|
||||
/** 折射表面的实际 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 最终可见外轮廓的圆角半径。 */
|
||||
@@ -65,12 +65,24 @@ export function getGlassNavbarOpticalResponse(
|
||||
export interface GlassNavbarDisplacementField {
|
||||
/** 位移图的 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 按 RGBA 顺序存储的非预乘像素通道。 */
|
||||
/** 非预乘 RGBA;R/B 为位移,panel 的 G 为中心散射权重,其余模式保持中性 G。 */
|
||||
pixels: Uint8ClampedArray
|
||||
/** 位移图的 CSS 像素宽度。 */
|
||||
width: number
|
||||
}
|
||||
|
||||
/** 稳定背板内各玻璃表面的局部坐标,按绘制顺序处理重叠区域。 */
|
||||
export interface GlassPanelBackdropGeometry {
|
||||
/** 背板的 CSS 像素宽度。 */
|
||||
width: number
|
||||
/** 背板的 CSS 像素高度。 */
|
||||
height: number
|
||||
/** 同一背板内、均匀圆角的导航轮廓。 */
|
||||
panels: Array<{ x: number; y: number; width: number; height: number; radius: number }>
|
||||
/** 背板各轮廓共用的有效光学参数。 */
|
||||
optics: GlassNavbarOpticalResponse
|
||||
}
|
||||
|
||||
export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP =
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
|
||||
|
||||
@@ -121,7 +133,7 @@ export function createGlassNavbarDisplacementField({
|
||||
radius,
|
||||
width,
|
||||
surface = 'navbar',
|
||||
optics = surface === 'sidebar'
|
||||
optics = surface !== 'navbar'
|
||||
? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||
: DEFAULT_NAVBAR_OPTICS,
|
||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||
@@ -135,12 +147,14 @@ export function createGlassNavbarDisplacementField({
|
||||
const maximumBand =
|
||||
surface === 'sidebar'
|
||||
? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
: surface === 'panel'
|
||||
? Math.min(48, radiusBand * 2, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
: Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 1] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 1] = surface === 'panel' ? 0 : DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
@@ -169,14 +183,25 @@ export function createGlassNavbarDisplacementField({
|
||||
)
|
||||
continue
|
||||
|
||||
if (surface === 'panel') {
|
||||
// 清亮边缘向散射中心连续过渡;合成时两路权重互补,避免透明度凹陷形成第二圈轮廓。
|
||||
const diffusionProgress = Math.min(1, (distanceInside - outerGuard) / Math.min(4, bandWidth / 3))
|
||||
pixels[(y * pixelWidth + x) * 4 + 1] = clampChannel(255 * smoothstep(diffusionProgress))
|
||||
}
|
||||
|
||||
// 横向平移从边缘透镜退出后进入,避免两种回落梯度叠加导致局部反向采样。
|
||||
const horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||
const verticalRamp =
|
||||
surface === 'sidebar'
|
||||
surface !== 'navbar'
|
||||
? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64)))
|
||||
: smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
|
||||
if (surface === 'panel' && distanceInside >= bandWidth) {
|
||||
pixels[(y * pixelWidth + x) * 4] = clampChannel(DISPLACEMENT_NEUTRAL_CHANNEL + translationChannel)
|
||||
continue
|
||||
}
|
||||
|
||||
if (pixelRadius === 0) {
|
||||
// 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。
|
||||
const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard)
|
||||
@@ -198,7 +223,7 @@ export function createGlassNavbarDisplacementField({
|
||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||
// 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。
|
||||
const verticalProfile = surface === 'sidebar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2
|
||||
const verticalProfile = surface !== 'navbar' ? profile : Math.sin(Math.PI * verticalProgress) ** 2
|
||||
const verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||
const gradientX =
|
||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||
@@ -220,9 +245,7 @@ export function createGlassNavbarDisplacementField({
|
||||
return { height: pixelHeight, pixels, width: pixelWidth }
|
||||
}
|
||||
|
||||
/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
|
||||
export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
|
||||
const field = createGlassNavbarDisplacementField(geometry)
|
||||
function encodeDisplacementField(field: GlassNavbarDisplacementField) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
@@ -238,6 +261,44 @@ export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplaceme
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
|
||||
export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
|
||||
return encodeDisplacementField(createGlassNavbarDisplacementField(geometry))
|
||||
}
|
||||
|
||||
/** 固定导航共用稳定壁纸输入,背板只在真实导航轮廓内进行局部折射。 */
|
||||
export function createGlassPanelBackdropField({ width, height, panels, optics }: GlassPanelBackdropGeometry) {
|
||||
const pixelWidth = normalizePixelSize(width)
|
||||
const pixelHeight = normalizePixelSize(height)
|
||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||
pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 1] = 255
|
||||
pixels[offset + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||
pixels[offset + 3] = 255
|
||||
}
|
||||
for (const panel of panels) {
|
||||
const field = createGlassNavbarDisplacementField({ ...panel, optics, surface: 'panel' })
|
||||
const left = Math.round(panel.x)
|
||||
const top = Math.round(panel.y)
|
||||
const radius = Math.max(0, Math.min(panel.radius, field.width / 2, field.height / 2))
|
||||
for (let y = Math.max(0, -top); y < Math.min(field.height, pixelHeight - top); y += 1) {
|
||||
for (let x = Math.max(0, -left); x < Math.min(field.width, pixelWidth - left); x += 1) {
|
||||
if (roundedRectangleSignedDistance(x + 0.5, y + 0.5, field.width, field.height, radius) > 0) continue
|
||||
const source = (y * field.width + x) * 4
|
||||
const destination = ((top + y) * pixelWidth + left + x) * 4
|
||||
pixels.set(field.pixels.subarray(source, source + 4), destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { width: pixelWidth, height: pixelHeight, pixels }
|
||||
}
|
||||
|
||||
/** 稳定背板与独立表面使用同一 PNG 编码与坐标精度。 */
|
||||
export function createGlassPanelBackdropMap(geometry: GlassPanelBackdropGeometry) {
|
||||
return encodeDisplacementField(createGlassPanelBackdropField(geometry))
|
||||
}
|
||||
|
||||
/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */
|
||||
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
|
||||
const brands = browserIdentity.userAgentData?.brands
|
||||
|
||||
Reference in New Issue
Block a user