feat(glass): extend blur-free panels and defer inactive GPU work

This commit is contained in:
InfinityPacer
2026-09-07 11:19:07 +08:00
parent 35f54fcc35
commit 851bcd8c82
13 changed files with 1549 additions and 102 deletions
@@ -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)
+136 -30
View File
@@ -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()
+14 -5
View File
@@ -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 和 uniformGPU 中性场必须等 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:')
+52 -9
View File
@@ -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;
+20 -29
View File
@@ -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])
+72 -11
View File
@@ -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 顺序存储的非预乘像素通道。 */
/** 非预乘 RGBAR/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)
: Math.min(REFRACTION_BAND_PX, radiusBand, 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