mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-10 10:16: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 VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||||
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
import GlassFixedShellBackplate from '@/components/theme/GlassFixedShellBackplate.vue'
|
||||||
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
import GlassNavbarRefractionDefs from '@/components/theme/GlassNavbarRefractionDefs.vue'
|
||||||
|
import GlassPanelRefractionDefs from '@/components/theme/GlassPanelRefractionDefs.vue'
|
||||||
import {
|
import {
|
||||||
readThemeCustomizerSettings,
|
readThemeCustomizerSettings,
|
||||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||||
@@ -239,6 +240,7 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
navbarRefractionMode === 'chromium' ? h(GlassNavbarRefractionDefs) : null,
|
||||||
|
navbarRefractionMode === 'chromium' ? h(GlassPanelRefractionDefs) : null,
|
||||||
fixedShellBackplateNode,
|
fixedShellBackplateNode,
|
||||||
verticalNav,
|
verticalNav,
|
||||||
h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]),
|
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" />' },
|
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', () => ({
|
vi.mock('@layouts/components/VerticalNav.vue', () => ({
|
||||||
default: { template: '<aside data-testid="vertical-nav"><slot /></aside>' },
|
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.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="navbar-refraction-defs"]').exists()).toBe(false)
|
||||||
|
expect(goal1Wrapper.find('[data-testid="panel-refraction-defs"]').exists()).toBe(false)
|
||||||
goal1Wrapper.unmount()
|
goal1Wrapper.unmount()
|
||||||
|
|
||||||
mocks.navbarRefractionSupported = true
|
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.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="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 () => {
|
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()
|
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 () => {
|
it('shares dynamics across nested hover cards without allocating another material slot', async () => {
|
||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
@@ -2880,35 +2927,49 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const visibilityState: DocumentVisibilityState = 'visible'
|
const visibilityState: DocumentVisibilityState = 'visible'
|
||||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||||
documentHasFocus = false
|
documentHasFocus = false
|
||||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
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 scope = effectScope()
|
||||||
const renderer = scope.run(() =>
|
const renderer = scope.run(() =>
|
||||||
useGlassOpticalRenderer({
|
useGlassOpticalRenderer({
|
||||||
active: ref(true),
|
active: ref(true),
|
||||||
appearance: ref('clear'),
|
appearance,
|
||||||
canvas: ref(canvas),
|
canvas: ref(canvas),
|
||||||
quality: ref('balanced'),
|
quality,
|
||||||
routeKey: ref('/dashboard'),
|
routeKey: ref('/dashboard'),
|
||||||
tintColor: ref('#8D51F9'),
|
tintColor: ref('#8D51F9'),
|
||||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
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(render).not.toHaveBeenCalled()
|
||||||
|
expect(compile).not.toHaveBeenCalled()
|
||||||
|
expect(upload).not.toHaveBeenCalled()
|
||||||
|
|
||||||
documentHasFocus = true
|
documentHasFocus = true
|
||||||
window.dispatchEvent(new Event('focus'))
|
window.dispatchEvent(new Event('focus'))
|
||||||
await nextTick()
|
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
expect(render).toHaveBeenCalled()
|
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()
|
scope.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3572,7 +3633,7 @@ describe('glass optical surface discovery', () => {
|
|||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||||
documentHasFocus = false
|
documentHasFocus = true
|
||||||
const scope = effectScope()
|
const scope = effectScope()
|
||||||
const renderer = scope.run(() =>
|
const renderer = scope.run(() =>
|
||||||
useGlassOpticalRenderer({
|
useGlassOpticalRenderer({
|
||||||
@@ -3590,6 +3651,7 @@ describe('glass optical surface discovery', () => {
|
|||||||
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
const dispose = vi.spyOn(three.WebGLRenderer.prototype, 'dispose')
|
||||||
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
const contextLoss = vi.spyOn(three.WebGLRenderer.prototype, 'forceContextLoss')
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
documentHasFocus = false
|
||||||
window.dispatchEvent(new Event('blur'))
|
window.dispatchEvent(new Event('blur'))
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
await vi.advanceTimersByTimeAsync(APP_ACTIVITY_SUSPEND_DELAY_MS)
|
||||||
@@ -4049,6 +4111,41 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 () => {
|
it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => {
|
||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -4060,6 +4157,10 @@ describe('glass optical surface discovery', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
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 addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
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(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||||
|
const baselineRenderCalls = render.mock.calls.length
|
||||||
|
|
||||||
scope.stop()
|
scope.stop()
|
||||||
|
expect(forceContextLoss).toHaveBeenCalledOnce()
|
||||||
|
expect(disposeRenderer).not.toHaveBeenCalled()
|
||||||
|
expect(disposeMaterial).not.toHaveBeenCalled()
|
||||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
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(ResizeObserverMock.instances).toHaveLength(0)
|
||||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Color,
|
Color,
|
||||||
IUniform,
|
IUniform,
|
||||||
Mesh,
|
Mesh,
|
||||||
|
Object3D,
|
||||||
OrthographicCamera,
|
OrthographicCamera,
|
||||||
Scene,
|
Scene,
|
||||||
ShaderMaterial,
|
ShaderMaterial,
|
||||||
@@ -314,6 +315,8 @@ interface GlassRendererUniforms extends Record<string, IUniform> {
|
|||||||
uRects: IUniform<Vector4[]>
|
uRects: IUniform<Vector4[]>
|
||||||
uSurfaceWeights: IUniform<number[]>
|
uSurfaceWeights: IUniform<number[]>
|
||||||
uSurfaceDynamics: IUniform<number[]>
|
uSurfaceDynamics: IUniform<number[]>
|
||||||
|
/** 原生材质已持有背景的表面不重复绘制静态壁纸,但继续消费共享动态场。 */
|
||||||
|
uSurfaceBaseWeights: IUniform<number[]>
|
||||||
uPreviousTexture: IUniform<Texture | null>
|
uPreviousTexture: IUniform<Texture | null>
|
||||||
uPreviousFrostedTexture: IUniform<Texture | null>
|
uPreviousFrostedTexture: IUniform<Texture | null>
|
||||||
uTexture: IUniform<Texture | null>
|
uTexture: IUniform<Texture | null>
|
||||||
@@ -344,6 +347,8 @@ interface GlassRendererResources {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GlassFrostPrefilterResources {
|
interface GlassFrostPrefilterResources {
|
||||||
|
/** 编译与释放预滤材质的 WebGL 资源所有者。 */
|
||||||
|
owner: GlassRendererResources
|
||||||
material: ShaderMaterial
|
material: ShaderMaterial
|
||||||
mesh: Mesh
|
mesh: Mesh
|
||||||
scene: Scene
|
scene: Scene
|
||||||
@@ -573,6 +578,7 @@ uniform vec4 uRects[8];
|
|||||||
uniform vec4 uRadii[8];
|
uniform vec4 uRadii[8];
|
||||||
uniform float uSurfaceWeights[8];
|
uniform float uSurfaceWeights[8];
|
||||||
uniform float uSurfaceDynamics[8];
|
uniform float uSurfaceDynamics[8];
|
||||||
|
uniform float uSurfaceBaseWeights[8];
|
||||||
uniform int uRectCount;
|
uniform int uRectCount;
|
||||||
uniform float uAppearance;
|
uniform float uAppearance;
|
||||||
uniform float uBackgroundVisibility;
|
uniform float uBackgroundVisibility;
|
||||||
@@ -814,6 +820,7 @@ vec2 softLimitDynamicRefraction(vec2 refraction) {
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
float mask = 0.0;
|
float mask = 0.0;
|
||||||
|
float baseMask = 0.0;
|
||||||
float edge = 0.0;
|
float edge = 0.0;
|
||||||
float caustic = 0.0;
|
float caustic = 0.0;
|
||||||
float directionalReflection = 0.0;
|
float directionalReflection = 0.0;
|
||||||
@@ -869,6 +876,7 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD}
|
|||||||
|
|
||||||
vec4 rect = uRects[i];
|
vec4 rect = uRects[i];
|
||||||
float surfaceDynamic = uSurfaceDynamics[i];
|
float surfaceDynamic = uSurfaceDynamics[i];
|
||||||
|
float surfaceBase = uSurfaceBaseWeights[i];
|
||||||
vec2 local = (vUv - rect.xy) / rect.zw;
|
vec2 local = (vUv - rect.xy) / rect.zw;
|
||||||
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
|
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
|
||||||
if (rectMask <= 0.0) continue;
|
if (rectMask <= 0.0) continue;
|
||||||
@@ -892,18 +900,19 @@ ${GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD}
|
|||||||
${GLASS_FLUID_FRAGMENT_SURFACE_SHAPE}
|
${GLASS_FLUID_FRAGMENT_SURFACE_SHAPE}
|
||||||
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
|
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
|
||||||
${GLASS_FLUID_FRAGMENT_SURFACE_OPTICS}
|
${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}
|
${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
||||||
dynamicRefraction += rippleRefraction * rippleMode * rectMask * surfaceDynamic * interactionMask;
|
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, localCaustic);
|
||||||
caustic = max(
|
caustic = max(
|
||||||
caustic,
|
caustic,
|
||||||
rippleGradientEnergy * rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask
|
rippleGradientEnergy * rippleState.z * rippleMode * rectMask * surfaceDynamic * interactionMask
|
||||||
);
|
);
|
||||||
directionalReflection = max(directionalReflection, localDirectionalReflection);
|
// 原生表面已有静态轮廓;交互只叠加局部位移与动态焦散,不重新点亮另一套静态棱边。
|
||||||
topPrism = max(topPrism, localTopPrism);
|
directionalReflection = max(directionalReflection, localDirectionalReflection * surfaceBase);
|
||||||
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
|
topPrism = max(topPrism, localTopPrism * surfaceBase);
|
||||||
|
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption * surfaceBase);
|
||||||
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask);
|
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask);
|
||||||
materialEnergy = max(
|
materialEnergy = max(
|
||||||
materialEnergy,
|
materialEnergy,
|
||||||
@@ -915,12 +924,18 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
|||||||
);
|
);
|
||||||
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask);
|
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask);
|
||||||
mask = max(mask, rectMask);
|
mask = max(mask, rectMask);
|
||||||
|
baseMask = max(baseMask, rectMask * uSurfaceBaseWeights[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mask <= 0.0) discard;
|
if (mask <= 0.0) discard;
|
||||||
|
|
||||||
float contentProtection = getContentProtection(coverUv(vUv + staticRefraction));
|
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
|
||||||
dynamicRefraction *= contentProtection;
|
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 *= 1.2;
|
||||||
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
|
||||||
@@ -1010,6 +1025,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
|||||||
caustic * proceduralCausticAlpha
|
caustic * proceduralCausticAlpha
|
||||||
) *
|
) *
|
||||||
uReflectionStrength;
|
uReflectionStrength;
|
||||||
|
if (dynamicsOnlyOutput) proceduralAlpha *= clamp(dynamicsPresence, 0.0, 1.0);
|
||||||
gl_FragColor = vec4(proceduralHighlight, proceduralAlpha);
|
gl_FragColor = vec4(proceduralHighlight, proceduralAlpha);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1028,8 +1044,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
|||||||
refracted = mix(refracted, highlight, reflectionMix);
|
refracted = mix(refracted, highlight, reflectionMix);
|
||||||
refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget;
|
refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget;
|
||||||
|
|
||||||
if (uDynamicsOnly > 0.5) {
|
if (dynamicsOnlyOutput) {
|
||||||
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
|
|
||||||
float dynamicsAlpha =
|
float dynamicsAlpha =
|
||||||
clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82);
|
clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82);
|
||||||
gl_FragColor = vec4(refracted, dynamicsAlpha);
|
gl_FragColor = vec4(refracted, dynamicsAlpha);
|
||||||
@@ -1039,7 +1054,7 @@ ${GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION}
|
|||||||
gl_FragColor = vec4(
|
gl_FragColor = vec4(
|
||||||
refracted,
|
refracted,
|
||||||
clamp(
|
clamp(
|
||||||
mask *
|
baseMask *
|
||||||
(
|
(
|
||||||
materialAlpha +
|
materialAlpha +
|
||||||
(
|
(
|
||||||
@@ -1240,6 +1255,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const failedWallpaperPreparationKey = ref('')
|
const failedWallpaperPreparationKey = ref('')
|
||||||
let three: ThreeModule | null = null
|
let three: ThreeModule | null = null
|
||||||
let resources: GlassRendererResources | 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 fluidDynamics: GlassFluidDynamics | null = null
|
||||||
let rippleResources: GlassRippleDynamics | null = null
|
let rippleResources: GlassRippleDynamics | null = null
|
||||||
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
|
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
|
||||||
@@ -1333,6 +1351,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
let resumeVersion = 0
|
let resumeVersion = 0
|
||||||
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
|
// 失焦后的暂停状态由活动事件解除,观察器与参数更新不能自行恢复呈现。
|
||||||
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
|
let presentationPaused = document.visibilityState === 'hidden' || !document.hasFocus()
|
||||||
|
let preparationDeferred = false
|
||||||
let dynamicsGeneration = 0
|
let dynamicsGeneration = 0
|
||||||
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
const presentationSpace = options.surfaceSpace ?? 'fixed'
|
||||||
const usesDynamicsOnly = () =>
|
const usesDynamicsOnly = () =>
|
||||||
@@ -1344,6 +1363,13 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
|
return toValue(options.active) && !presentationPaused && document.visibilityState !== 'hidden'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 后台只保留最新配置;恢复时合并重建,避免准备完的纹理和波场立即被释放。 */
|
||||||
|
function canPrepareResources() {
|
||||||
|
if (canPresentFrame()) return true
|
||||||
|
preparationDeferred = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
|
||||||
function syncWallpaperSamplingMode() {
|
function syncWallpaperSamplingMode() {
|
||||||
if (!resources) return
|
if (!resources) return
|
||||||
@@ -1622,12 +1648,34 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
|
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 单独持有。 */
|
/** 释放壁纸准备阶段复用的预滤 shader;活动低通纹理由各自 RenderTarget 单独持有。 */
|
||||||
function disposeFrostPrefilterResources() {
|
function disposeFrostPrefilterResources() {
|
||||||
if (!frostPrefilterResources) return
|
if (!frostPrefilterResources) return
|
||||||
|
|
||||||
frostPrefilterResources.material.dispose()
|
const retired = frostPrefilterResources
|
||||||
frostPrefilterResources = null
|
frostPrefilterResources = null
|
||||||
|
disposeAfterCompilation(retired.owner, () => retired.material.dispose())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
|
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
|
||||||
@@ -1651,13 +1699,14 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const mesh = new three.Mesh(resources.geometry, material)
|
const mesh = new three.Mesh(resources.geometry, material)
|
||||||
mesh.frustumCulled = false
|
mesh.frustumCulled = false
|
||||||
scene.add(mesh)
|
scene.add(mesh)
|
||||||
frostPrefilterResources = { material, mesh, scene, uniforms }
|
frostPrefilterResources = { owner: resources, material, mesh, scene, uniforms }
|
||||||
|
|
||||||
return frostPrefilterResources
|
return frostPrefilterResources
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 壁纸上传时执行两次 separable blur,常态只保留既定分辨率的低通 RenderTarget。 */
|
/** 壁纸上传时执行两次 separable blur,常态只保留既定分辨率的低通 RenderTarget。 */
|
||||||
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number, targetLongEdge: number) {
|
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number, targetLongEdge: number) {
|
||||||
|
if (!canPrepareResources()) return null
|
||||||
if (!resources || !three) return null
|
if (!resources || !three) return null
|
||||||
|
|
||||||
const ownerResources = resources
|
const ownerResources = resources
|
||||||
@@ -1681,8 +1730,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ownerResources.renderer.initTexture(texture)
|
ownerResources.renderer.initTexture(texture)
|
||||||
await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera)
|
await compileOwnedScene(ownerResources, prefilter.scene)
|
||||||
if (resources !== ownerResources) {
|
if (resources !== ownerResources || !canPrepareResources()) {
|
||||||
outputTarget.dispose()
|
outputTarget.dispose()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1759,6 +1808,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
/** 只在水漾被选中时为当前 context 编译并分配独占 ping-pong 场。 */
|
/** 只在水漾被选中时为当前 context 编译并分配独占 ping-pong 场。 */
|
||||||
async function syncRippleResources() {
|
async function syncRippleResources() {
|
||||||
if (!resources || !three) return
|
if (!resources || !three) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
if (!hasRippleCapability()) {
|
if (!hasRippleCapability()) {
|
||||||
disposeRippleResources()
|
disposeRippleResources()
|
||||||
return
|
return
|
||||||
@@ -1778,9 +1828,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
three,
|
three,
|
||||||
viewportHeight: window.innerHeight,
|
viewportHeight: window.innerHeight,
|
||||||
viewportWidth: window.innerWidth,
|
viewportWidth: window.innerWidth,
|
||||||
|
compile: scene => compileOwnedScene(ownerResources, scene),
|
||||||
|
isCurrent: () =>
|
||||||
|
generation === dynamicsGeneration &&
|
||||||
|
resources === ownerResources &&
|
||||||
|
hasRippleCapability() &&
|
||||||
|
canPrepareResources(),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) return
|
if (
|
||||||
|
generation !== dynamicsGeneration ||
|
||||||
|
resources !== ownerResources ||
|
||||||
|
!hasRippleCapability() ||
|
||||||
|
!canPrepareResources()
|
||||||
|
)
|
||||||
|
return
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) {
|
if (generation !== dynamicsGeneration || resources !== ownerResources || !hasRippleCapability()) {
|
||||||
@@ -1801,6 +1863,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
/** 模式切换时同步释放旧策略、清空输入历史并在资源就绪后恢复订阅。 */
|
/** 模式切换时同步释放旧策略、清空输入历史并在资源就绪后恢复订阅。 */
|
||||||
async function syncDynamicsMode() {
|
async function syncDynamicsMode() {
|
||||||
if (!resources) return
|
if (!resources) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
|
|
||||||
interactionAnimating = false
|
interactionAnimating = false
|
||||||
activeTouchIdentifier = null
|
activeTouchIdentifier = null
|
||||||
@@ -1845,7 +1908,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
|
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
|
||||||
if (!resources || !canPresentFrame()) return
|
// 首次 resize/observer 只能同步几何,不能在异步预编译前触发同步材质编译。
|
||||||
|
if (!resources || !canPresentFrame() || !compiledMainScenes.has(resources)) return
|
||||||
|
|
||||||
updateWallpaperTransition(timestamp)
|
updateWallpaperTransition(timestamp)
|
||||||
if (fluidDynamics && advanceFlow) {
|
if (fluidDynamics && advanceFlow) {
|
||||||
@@ -1923,6 +1987,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const uniformRadii = resources.uniforms.uRadii.value
|
const uniformRadii = resources.uniforms.uRadii.value
|
||||||
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
||||||
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
||||||
|
const uniformBaseWeights = resources.uniforms.uSurfaceBaseWeights.value
|
||||||
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
|
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
|
||||||
const transitionWeights = outgoingSurface
|
const transitionWeights = outgoingSurface
|
||||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||||
@@ -1952,6 +2017,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
const nestedInteractionAvailable =
|
const nestedInteractionAvailable =
|
||||||
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
|
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
|
||||||
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
|
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
|
resources.uniforms.uRectCount.value = normalized.length
|
||||||
@@ -3108,8 +3179,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (version !== resumeVersion || !canResume()) return
|
if (version !== resumeVersion || !canResume()) return
|
||||||
if (!resources) {
|
if (!resources || preparationDeferred) {
|
||||||
await initializeRenderer()
|
await initializeRenderer(false)
|
||||||
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
if (canPresentFrame() && !pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3126,6 +3197,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
animationFrame = requestAnimationFrame(renderInteractionFrame)
|
animationFrame = requestAnimationFrame(renderInteractionFrame)
|
||||||
}
|
}
|
||||||
scheduleWallpaperTransition()
|
scheduleWallpaperTransition()
|
||||||
|
preparePendingWallpaper()
|
||||||
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
if (!pagePresentationGeometryReady) scheduleSurfaceStabilityUpdate()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -3283,7 +3355,12 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
observedMutationRoots.add(root)
|
observedMutationRoots.add(root)
|
||||||
surfaceMutationObserver?.observe(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,
|
attributes: true,
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree,
|
subtree,
|
||||||
@@ -3293,6 +3370,18 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
surfaceMutationObserver = new MutationObserver(mutations => {
|
surfaceMutationObserver = new MutationObserver(mutations => {
|
||||||
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
|
||||||
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
|
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
|
if (!mutationTouchesOpticalSurface(mutations)) return
|
||||||
|
|
||||||
interactionClipMembershipDirty = true
|
interactionClipMembershipDirty = true
|
||||||
@@ -3388,6 +3477,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disposeRenderer(releaseContext = true) {
|
function disposeRenderer(releaseContext = true) {
|
||||||
|
if (resources) retiredResources.add(resources)
|
||||||
resumeVersion += 1
|
resumeVersion += 1
|
||||||
loadVersion += 1
|
loadVersion += 1
|
||||||
prepareVersion += 1
|
prepareVersion += 1
|
||||||
@@ -3458,11 +3548,15 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
disposeRippleResources()
|
disposeRippleResources()
|
||||||
disposeFrostPrefilterResources()
|
disposeFrostPrefilterResources()
|
||||||
if (resources) {
|
if (resources) {
|
||||||
resources.geometry.dispose()
|
const retired = resources
|
||||||
resources.material.dispose()
|
|
||||||
resources.renderer.dispose()
|
|
||||||
if (releaseContext) resources.renderer.forceContextLoss()
|
|
||||||
resources = null
|
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
|
delete document.documentElement.dataset.glassRendererState
|
||||||
@@ -3479,6 +3573,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
/** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */
|
/** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */
|
||||||
async function refreshWallpaper(message: string, beforeActivate?: () => void) {
|
async function refreshWallpaper(message: string, beforeActivate?: () => void) {
|
||||||
|
if (!canPrepareResources()) return
|
||||||
const version = ++loadVersion
|
const version = ++loadVersion
|
||||||
const retainsActiveTexture = Boolean(resources && activeTexture)
|
const retainsActiveTexture = Boolean(resources && activeTexture)
|
||||||
if (!retainsActiveTexture) updateRendererState('loading')
|
if (!retainsActiveTexture) updateRendererState('loading')
|
||||||
@@ -3696,6 +3791,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
releasePreparedWallpaper()
|
releasePreparedWallpaper()
|
||||||
clearPreparedWallpaperFailure()
|
clearPreparedWallpaperFailure()
|
||||||
if (!url || !resources || contextRecoveryPending || url === toValue(options.wallpaperUrl)) return
|
if (!url || !resources || contextRecoveryPending || url === toValue(options.wallpaperUrl)) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
const preparationKey = getWallpaperPreparationKey(url)
|
const preparationKey = getWallpaperPreparationKey(url)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3706,7 +3802,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
!resources ||
|
!resources ||
|
||||||
contextRecoveryPending ||
|
contextRecoveryPending ||
|
||||||
preparationKey !== prepared.preparationKey ||
|
preparationKey !== prepared.preparationKey ||
|
||||||
preparationKey !== getWallpaperPreparationKey(url)
|
preparationKey !== getWallpaperPreparationKey(url) ||
|
||||||
|
!canPrepareResources()
|
||||||
) {
|
) {
|
||||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||||
return
|
return
|
||||||
@@ -3714,7 +3811,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
resources.renderer.initTexture(prepared.texture)
|
resources.renderer.initTexture(prepared.texture)
|
||||||
recordGlassRendererTiming(presentationSpace, 'prepare-compile-start')
|
recordGlassRendererTiming(presentationSpace, 'prepare-compile-start')
|
||||||
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
await compileOwnedScene(resources, resources.scene)
|
||||||
recordGlassRendererTiming(presentationSpace, 'prepare-compile-ready')
|
recordGlassRendererTiming(presentationSpace, 'prepare-compile-ready')
|
||||||
if (
|
if (
|
||||||
version !== prepareVersion ||
|
version !== prepareVersion ||
|
||||||
@@ -3742,6 +3839,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
|
|
||||||
async function loadWallpaper(url: string, version: number, beforeActivate?: () => void) {
|
async function loadWallpaper(url: string, version: number, beforeActivate?: () => void) {
|
||||||
if (!resources || !three || !url) return
|
if (!resources || !three || !url) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
|
|
||||||
const prepared = await createWallpaperTexture(url)
|
const prepared = await createWallpaperTexture(url)
|
||||||
if (!prepared) return
|
if (!prepared) return
|
||||||
@@ -3749,20 +3847,22 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
version !== loadVersion ||
|
version !== loadVersion ||
|
||||||
!resources ||
|
!resources ||
|
||||||
contextRecoveryPending ||
|
contextRecoveryPending ||
|
||||||
prepared.preparationKey !== getWallpaperPreparationKey(url)
|
prepared.preparationKey !== getWallpaperPreparationKey(url) ||
|
||||||
|
!canPrepareResources()
|
||||||
) {
|
) {
|
||||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
recordGlassRendererTiming(presentationSpace, 'compile-start')
|
recordGlassRendererTiming(presentationSpace, 'compile-start')
|
||||||
await resources.renderer.compileAsync(resources.scene, resources.camera)
|
await compileOwnedScene(resources, resources.scene)
|
||||||
recordGlassRendererTiming(presentationSpace, 'compile-ready')
|
recordGlassRendererTiming(presentationSpace, 'compile-ready')
|
||||||
if (
|
if (
|
||||||
version !== loadVersion ||
|
version !== loadVersion ||
|
||||||
!resources ||
|
!resources ||
|
||||||
contextRecoveryPending ||
|
contextRecoveryPending ||
|
||||||
prepared.preparationKey !== getWallpaperPreparationKey(url)
|
prepared.preparationKey !== getWallpaperPreparationKey(url) ||
|
||||||
|
!canPrepareResources()
|
||||||
) {
|
) {
|
||||||
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
|
||||||
return
|
return
|
||||||
@@ -3901,6 +4001,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeRenderer(releaseContext = true) {
|
async function initializeRenderer(releaseContext = true) {
|
||||||
|
if (!canPrepareResources()) return
|
||||||
|
preparationDeferred = false
|
||||||
recordGlassRendererTiming(presentationSpace, 'initialize-start')
|
recordGlassRendererTiming(presentationSpace, 'initialize-start')
|
||||||
disposeRenderer(releaseContext)
|
disposeRenderer(releaseContext)
|
||||||
if (!toValue(options.active) || !options.canvas.value) return
|
if (!toValue(options.active) || !options.canvas.value) return
|
||||||
@@ -3916,7 +4018,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
try {
|
try {
|
||||||
three = await import('three')
|
three = await import('three')
|
||||||
recordGlassRendererTiming(presentationSpace, 'three-ready')
|
recordGlassRendererTiming(presentationSpace, 'three-ready')
|
||||||
if (version !== loadVersion || !options.canvas.value) return
|
if (version !== loadVersion || !options.canvas.value || !canPrepareResources()) return
|
||||||
const Vector4Class = three.Vector4
|
const Vector4Class = three.Vector4
|
||||||
const canvas = options.canvas.value
|
const canvas = options.canvas.value
|
||||||
const context = prepareGlassWebGLContext(canvas)
|
const context = prepareGlassWebGLContext(canvas)
|
||||||
@@ -3974,6 +4076,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
|
||||||
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
|
||||||
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
|
||||||
|
uSurfaceBaseWeights: { value: Array.from({ length: 8 }, () => 1) },
|
||||||
uPreviousTexture: { value: null },
|
uPreviousTexture: { value: null },
|
||||||
uPreviousFrostedTexture: { value: null },
|
uPreviousFrostedTexture: { value: null },
|
||||||
uTexture: { value: null },
|
uTexture: { value: null },
|
||||||
@@ -4018,7 +4121,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
version !== loadVersion ||
|
version !== loadVersion ||
|
||||||
resources !== ownerResources ||
|
resources !== ownerResources ||
|
||||||
!toValue(options.active) ||
|
!toValue(options.active) ||
|
||||||
options.canvas.value !== canvas
|
options.canvas.value !== canvas ||
|
||||||
|
!canPrepareResources()
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4115,6 +4219,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
() => toValue(options.appearance),
|
() => toValue(options.appearance),
|
||||||
async (appearance, previousAppearance) => {
|
async (appearance, previousAppearance) => {
|
||||||
if (!resources) return
|
if (!resources) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
|
|
||||||
const applyAppearance = () => {
|
const applyAppearance = () => {
|
||||||
if (!resources || toValue(options.appearance) !== appearance) return
|
if (!resources || toValue(options.appearance) !== appearance) return
|
||||||
@@ -4170,6 +4275,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
|||||||
() => toValue(options.quality),
|
() => toValue(options.quality),
|
||||||
async (quality, previousQuality) => {
|
async (quality, previousQuality) => {
|
||||||
if (!resources) return
|
if (!resources) return
|
||||||
|
if (!canPrepareResources()) return
|
||||||
|
|
||||||
const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey))
|
const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey))
|
||||||
const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey))
|
const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey))
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function createRippleHarness(
|
|||||||
} as unknown as typeof import('three')
|
} as unknown as typeof import('three')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
create: () =>
|
create: (overrides: Partial<Parameters<typeof createGlassRippleDynamics>[0]> = {}) =>
|
||||||
createGlassRippleDynamics({
|
createGlassRippleDynamics({
|
||||||
camera: {} as never,
|
camera: {} as never,
|
||||||
geometry: {} as never,
|
geometry: {} as never,
|
||||||
@@ -162,6 +162,7 @@ function createRippleHarness(
|
|||||||
three,
|
three,
|
||||||
viewportHeight: 800,
|
viewportHeight: 800,
|
||||||
viewportWidth: 1200,
|
viewportWidth: 1200,
|
||||||
|
...overrides,
|
||||||
}),
|
}),
|
||||||
renderer,
|
renderer,
|
||||||
snapshots,
|
snapshots,
|
||||||
@@ -174,6 +175,50 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('glass ripple dynamics', () => {
|
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 () => {
|
it('uses one bounded half-float ping-pong field when the renderer supports it', async () => {
|
||||||
const harness = createRippleHarness()
|
const harness = createRippleHarness()
|
||||||
const dynamics = await harness.create()
|
const dynamics = await harness.create()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BufferGeometry,
|
BufferGeometry,
|
||||||
IUniform,
|
IUniform,
|
||||||
|
Object3D,
|
||||||
OrthographicCamera,
|
OrthographicCamera,
|
||||||
Texture,
|
Texture,
|
||||||
Vector2,
|
Vector2,
|
||||||
@@ -71,6 +72,10 @@ interface CreateGlassRippleDynamicsOptions {
|
|||||||
three: ThreeModule
|
three: ThreeModule
|
||||||
viewportHeight: number
|
viewportHeight: number
|
||||||
viewportWidth: number
|
viewportWidth: number
|
||||||
|
/** 由 owner 跟踪的异步编译,确保编译检查结束前资源保持有效。 */
|
||||||
|
compile?: (scene: Object3D) => Promise<unknown>
|
||||||
|
/** 代次失效后不再初始化或呈现波场。 */
|
||||||
|
isCurrent?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const RIPPLE_VERTEX_SHADER = `
|
const RIPPLE_VERTEX_SHADER = `
|
||||||
@@ -233,6 +238,8 @@ export async function createGlassRippleDynamics(
|
|||||||
let clearOnNextFrame = false
|
let clearOnNextFrame = false
|
||||||
let fieldActive = false
|
let fieldActive = false
|
||||||
let disposed = 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 targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType
|
||||||
|
|
||||||
const createTarget = () => {
|
const createTarget = () => {
|
||||||
@@ -332,7 +339,7 @@ export async function createGlassRippleDynamics(
|
|||||||
}
|
}
|
||||||
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
|
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
|
||||||
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
||||||
reset()
|
if (compilationSettled) reset()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -365,10 +372,12 @@ export async function createGlassRippleDynamics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initializedByResize = resize(viewportWidth, viewportHeight)
|
resize(viewportWidth, viewportHeight)
|
||||||
await renderer.compileAsync(scene, camera)
|
await (options.compile ? options.compile(scene) : renderer.compileAsync(scene, camera))
|
||||||
if (disposed) throw new Error('Ripple resources were disposed during compilation')
|
if (disposed || options.isCurrent?.() === false)
|
||||||
if (!initializedByResize) reset()
|
throw new Error('Ripple resources were superseded during compilation')
|
||||||
|
compilationSettled = true
|
||||||
|
reset()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
material.dispose()
|
material.dispose()
|
||||||
readTarget.dispose()
|
readTarget.dispose()
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ describe('glass overlay material styles', () => {
|
|||||||
it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => {
|
it('keeps overlays translucent enough for CSS backdrop compositing in every material', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
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).toContain('calc(0.58 + var(--glass-surface-density, 0.62) * 0.18)')
|
||||||
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.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: 115%')
|
||||||
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
||||||
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
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\)/,
|
/\.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(
|
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(
|
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(
|
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\)/,
|
/&\[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', () => {
|
it('shares the same light frost when glass navbars overlap scrolled content', () => {
|
||||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
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(
|
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;/,
|
/: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-background-visibility, 0.58)')
|
||||||
expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)')
|
expect(baseMaterialRule).toContain('var(--glass-surface-density, 0.62)')
|
||||||
expect(baseMaterialRule).toContain('--glass-navbar-blur: clamp(')
|
expect(baseMaterialRule).not.toContain('blur(')
|
||||||
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).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)')
|
expect(baseMaterialRule).toContain('--glass-navbar-brightness: var(--glass-transmission-brightness, 1)')
|
||||||
expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(')
|
expect(baseMaterialRule).toContain('--glass-navbar-saturation: clamp(')
|
||||||
expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(')
|
expect(baseMaterialRule).toContain('--glass-navbar-sheen: linear-gradient(')
|
||||||
@@ -515,7 +514,7 @@ describe('glass overlay material styles', () => {
|
|||||||
expect(baseMaterialRule).toContain(
|
expect(baseMaterialRule).toContain(
|
||||||
'--glass-navbar-tint: clamp(0, calc(var(--glass-tint-density, 0.65) * 0.12), 0.18)',
|
'--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('brightness(var(--glass-navbar-brightness))')
|
||||||
expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important')
|
expect(baseMaterialRule).toContain('background: var(--glass-navbar-sheen), var(--glass-navbar-scrim) !important')
|
||||||
expect(baseMaterialRule).toContain('box-shadow: var(--glass-navbar-shadow) !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("data-glass-navbar-refraction-ready='true'")
|
||||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')")
|
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-balanced')")
|
||||||
expect(svgFilterRule).toContain("url('#glass-navbar-live-refraction-high')")
|
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('saturate(var(--glass-navbar-saturation))')
|
||||||
expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))')
|
expect(svgFilterRule).toContain('brightness(var(--glass-navbar-brightness))')
|
||||||
expect(svgFilterRule).not.toContain('background:')
|
expect(svgFilterRule).not.toContain('background:')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
@mixin surfaces {
|
@mixin surfaces {
|
||||||
html[data-theme='glass'] {
|
html[data-theme='glass'] {
|
||||||
--glass-v3-ink: 23, 27, 32;
|
--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-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-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);
|
--glass-v3-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
||||||
@@ -14,13 +14,7 @@
|
|||||||
--glass-v3-navigation-inset: 8px;
|
--glass-v3-navigation-inset: 8px;
|
||||||
--glass-v3-navigation-content-gap: 16px;
|
--glass-v3-navigation-content-gap: 16px;
|
||||||
--glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
--glass-v3-navigation-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
--glass-v3-navigation-blur: clamp(
|
--glass-v3-navigation-filter: saturate(118%) brightness(var(--glass-transmission-brightness, 1));
|
||||||
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-card-background:
|
--glass-v3-card-background:
|
||||||
linear-gradient(128deg, rgba(255, 255, 255, var(--glass-v3-sheen)), transparent 38%),
|
linear-gradient(128deg, rgba(255, 255, 255, var(--glass-v3-sheen)), transparent 38%),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@@ -210,6 +204,36 @@
|
|||||||
overflow: clip;
|
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 {
|
.layout-page-content {
|
||||||
padding-block-start: calc(
|
padding-block-start: calc(
|
||||||
@@ -249,7 +273,7 @@
|
|||||||
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
html[data-theme='glass']:is([data-glass-appearance='clear'], [data-glass-appearance='tinted'])
|
||||||
body[data-theme='glass']
|
body[data-theme='glass']
|
||||||
.layout-wrapper[data-shell-mode='desktop']:not(.layout-horizontal-nav-active) {
|
.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);
|
--glass-navbar-live-filter: var(--glass-v3-navigation-filter);
|
||||||
|
|
||||||
transform: none !important;
|
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) {
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
html[data-theme='glass'] {
|
html[data-theme='glass'] {
|
||||||
--glass-v3-navigation-filter: none !important;
|
--glass-v3-navigation-filter: none !important;
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ html[data-theme='glass'] {
|
|||||||
--glass-control-backdrop-filter: none;
|
--glass-control-backdrop-filter: none;
|
||||||
--glass-control-prominent-backdrop-filter: none;
|
--glass-control-prominent-backdrop-filter: none;
|
||||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
--glass-navbar-scrolled-backdrop-filter: saturate(115%);
|
||||||
--glass-navbar-live-filter: none;
|
--glass-navbar-live-filter: none;
|
||||||
--glass-sidebar-live-filter: var(--glass-fixed-shell-backdrop-filter);
|
--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-saturate: 115%;
|
||||||
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
||||||
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
--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-border: rgba(255, 255, 255, 12%);
|
||||||
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
|
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
|
||||||
// Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。
|
// Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。
|
||||||
--glass-chip-backdrop-filter: blur(8px) saturate(150%) brightness(var(--glass-transmission-brightness));
|
--glass-chip-backdrop-filter: saturate(150%) brightness(var(--glass-transmission-brightness));
|
||||||
--glass-chip-sheen: linear-gradient(
|
--glass-chip-sheen:
|
||||||
135deg,
|
linear-gradient(135deg, rgba(255, 255, 255, 12%), transparent 38%, rgba(255, 255, 255, 3%) 72%, transparent),
|
||||||
rgba(255, 255, 255, 12%),
|
linear-gradient(rgba(11, 19, 34, 36%), rgba(11, 19, 34, 36%));
|
||||||
transparent 38%,
|
|
||||||
rgba(255, 255, 255, 3%) 72%,
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
--glass-chip-tint-opacity: calc(0.22 + var(--glass-tint-density, 0.65) * 0.18);
|
--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: rgba(255, 255, 255, 8%);
|
||||||
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
|
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
|
||||||
@@ -187,13 +184,13 @@ html[data-theme='glass'] {
|
|||||||
);
|
);
|
||||||
--glass-overlay-surface: color-mix(
|
--glass-overlay-surface: color-mix(
|
||||||
in srgb,
|
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))
|
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-saturate: 120%;
|
||||||
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
--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),
|
calc(0.045 + var(--glass-surface-density, 0.62) * 0.1 + (1 - var(--glass-background-visibility, 0.58)) * 0.05),
|
||||||
0.28
|
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-brightness: var(--glass-transmission-brightness, 1);
|
||||||
--glass-navbar-saturation: clamp(
|
--glass-navbar-saturation: clamp(
|
||||||
110%,
|
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)),
|
inset 0 -1px 2px rgba(4, 10, 20, calc(0.1 + var(--glass-surface-density, 0.62) * 0.12)),
|
||||||
0 12px 32px
|
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));
|
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))
|
--glass-navbar-live-filter: saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
||||||
brightness(var(--glass-navbar-brightness));
|
|
||||||
|
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
-webkit-backdrop-filter: var(--glass-navbar-live-filter) !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']
|
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-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') blur(var(--glass-navbar-blur))
|
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-high') saturate(var(--glass-navbar-saturation))
|
||||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
brightness(var(--glass-navbar-brightness));
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme='glass'][data-glass-quality='balanced']:is(
|
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']
|
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-wrapper[data-glass-navbar-refraction='chromium'][data-glass-navbar-refraction-ready='true'].layout-navbar-floating-eligible.layout-navbar-away-from-top
|
||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') blur(var(--glass-navbar-blur))
|
--glass-navbar-live-filter: url('#glass-navbar-live-refraction-balanced') saturate(var(--glass-navbar-saturation))
|
||||||
saturate(var(--glass-navbar-saturation)) brightness(var(--glass-navbar-brightness));
|
brightness(var(--glass-navbar-brightness));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。
|
// 常驻侧栏保持附着几何,只有同源位移图解码完成后才接管其单独的背景表面。
|
||||||
@@ -1843,8 +1834,8 @@ html[data-theme='glass'][data-glass-quality='high']:is(
|
|||||||
)
|
)
|
||||||
body[data-theme='glass']
|
body[data-theme='glass']
|
||||||
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||||
.layout-vertical-nav:not(.overlay-nav) {
|
.layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) {
|
||||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') blur(1px) saturate(115%)
|
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-high') saturate(115%)
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
|
||||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
-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']
|
body[data-theme='glass']
|
||||||
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
.layout-wrapper[data-glass-navigation-refraction='chromium'][data-glass-sidebar-refraction-ready='true']
|
||||||
.layout-vertical-nav:not(.overlay-nav) {
|
.layout-vertical-nav:not(.overlay-nav):not([data-glass-panel-refraction]) {
|
||||||
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') blur(1px) saturate(115%)
|
--glass-sidebar-live-filter: url('#glass-sidebar-live-refraction-balanced') saturate(115%)
|
||||||
brightness(var(--glass-transmission-brightness));
|
brightness(var(--glass-transmission-brightness));
|
||||||
|
|
||||||
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
-webkit-backdrop-filter: var(--glass-sidebar-live-filter) !important;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
createGlassNavbarDisplacementField,
|
createGlassNavbarDisplacementField,
|
||||||
|
createGlassPanelBackdropField,
|
||||||
getGlassNavbarOpticalResponse,
|
getGlassNavbarOpticalResponse,
|
||||||
getGlassSidebarOpticalResponse,
|
getGlassSidebarOpticalResponse,
|
||||||
supportsGlassNavbarLiveRefraction,
|
supportsGlassNavbarLiveRefraction,
|
||||||
@@ -81,6 +82,34 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
expect(pixelAt(field, 50, 35)[2]).toBeGreaterThan(128)
|
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([
|
it.each([
|
||||||
{ width: 1423, height: 64, radius: 16 },
|
{ width: 1423, height: 64, radius: 16 },
|
||||||
{ width: 401, height: 72, radius: 16 },
|
{ width: 401, height: 72, radius: 16 },
|
||||||
@@ -97,6 +126,10 @@ describe('createGlassNavbarDisplacementField', () => {
|
|||||||
...[60, 252].flatMap(width =>
|
...[60, 252].flatMap(width =>
|
||||||
[0, 8, 12, 16, 20, 24].map(radius => ({ width, height: 180, radius, surface: 'sidebar' as const })),
|
[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 => {
|
])('keeps two-dimensional sampling forward and inside the image for $width x $height r$radius', geometry => {
|
||||||
for (const deformation of [0, 48, 100])
|
for (const deformation of [0, 48, 100])
|
||||||
for (const translation of [0, 48, 100])
|
for (const translation of [0, 48, 100])
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export interface GlassNavbarRefractionBrowserIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GlassNavbarDisplacementGeometry {
|
export interface GlassNavbarDisplacementGeometry {
|
||||||
/** 侧栏使用四边等向的窄透镜;省略时保留顶栏的横向阅读保护方案。 */
|
/** 顶栏保护字形高度;侧栏使用窄边透镜;大面板沿长边展开光学过渡。 */
|
||||||
surface?: 'navbar' | 'sidebar'
|
surface?: 'navbar' | 'sidebar' | 'panel'
|
||||||
/** 折射表面的实际 CSS 像素高度。 */
|
/** 折射表面的实际 CSS 像素高度。 */
|
||||||
height: number
|
height: number
|
||||||
/** 最终可见外轮廓的圆角半径。 */
|
/** 最终可见外轮廓的圆角半径。 */
|
||||||
@@ -65,12 +65,24 @@ export function getGlassNavbarOpticalResponse(
|
|||||||
export interface GlassNavbarDisplacementField {
|
export interface GlassNavbarDisplacementField {
|
||||||
/** 位移图的 CSS 像素高度。 */
|
/** 位移图的 CSS 像素高度。 */
|
||||||
height: number
|
height: number
|
||||||
/** 按 RGBA 顺序存储的非预乘像素通道。 */
|
/** 非预乘 RGBA;R/B 为位移,panel 的 G 为中心散射权重,其余模式保持中性 G。 */
|
||||||
pixels: Uint8ClampedArray
|
pixels: Uint8ClampedArray
|
||||||
/** 位移图的 CSS 像素宽度。 */
|
/** 位移图的 CSS 像素宽度。 */
|
||||||
width: number
|
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 =
|
export const NEUTRAL_GLASS_NAVBAR_DISPLACEMENT_MAP =
|
||||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="1" height="1"%3E%3Cpath fill="%23808080" d="M0 0h1v1H0z"/%3E%3C/svg%3E'
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ export function createGlassNavbarDisplacementField({
|
|||||||
radius,
|
radius,
|
||||||
width,
|
width,
|
||||||
surface = 'navbar',
|
surface = 'navbar',
|
||||||
optics = surface === 'sidebar'
|
optics = surface !== 'navbar'
|
||||||
? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
? getGlassSidebarOpticalResponse(getGlassOpticalPresetParameters('clear', 'high', 'natural'))
|
||||||
: DEFAULT_NAVBAR_OPTICS,
|
: DEFAULT_NAVBAR_OPTICS,
|
||||||
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
}: GlassNavbarDisplacementGeometry): GlassNavbarDisplacementField {
|
||||||
@@ -135,12 +147,14 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const maximumBand =
|
const maximumBand =
|
||||||
surface === 'sidebar'
|
surface === 'sidebar'
|
||||||
? Math.min(12, pixelRadius || 12, Math.min(pixelWidth, pixelHeight) / 2)
|
? 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)
|
: Math.min(REFRACTION_BAND_PX, radiusBand, Math.min(pixelWidth, pixelHeight) / 2)
|
||||||
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
const pixels = new Uint8ClampedArray(pixelWidth * pixelHeight * 4)
|
||||||
|
|
||||||
for (let offset = 0; offset < pixels.length; offset += 4) {
|
for (let offset = 0; offset < pixels.length; offset += 4) {
|
||||||
pixels[offset] = DISPLACEMENT_NEUTRAL_CHANNEL
|
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 + 2] = DISPLACEMENT_NEUTRAL_CHANNEL
|
||||||
pixels[offset + 3] = 255
|
pixels[offset + 3] = 255
|
||||||
}
|
}
|
||||||
@@ -169,14 +183,25 @@ export function createGlassNavbarDisplacementField({
|
|||||||
)
|
)
|
||||||
continue
|
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 horizontalRamp = smoothstep(Math.max(0, Math.min(1, (edgeX - maximumBand) / 64)))
|
||||||
const verticalRamp =
|
const verticalRamp =
|
||||||
surface === 'sidebar'
|
surface !== 'navbar'
|
||||||
? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64)))
|
? smoothstep(Math.max(0, Math.min(1, (edgeY - maximumBand) / 64)))
|
||||||
: smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
: smoothstep(Math.min(1, (edgeY - outerGuard) / Math.max(1, Math.min(12, maximumBand))))
|
||||||
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
const translationChannel = (optics.translationPx * horizontalRamp * verticalRamp * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
|
|
||||||
|
if (surface === 'panel' && distanceInside >= bandWidth) {
|
||||||
|
pixels[(y * pixelWidth + x) * 4] = clampChannel(DISPLACEMENT_NEUTRAL_CHANNEL + translationChannel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (pixelRadius === 0) {
|
if (pixelRadius === 0) {
|
||||||
// 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。
|
// 矩形的四条直边分别取样,角点用叠加的轴向剖面保持连续,不伪造圆角法线。
|
||||||
const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard)
|
const leftProfile = refractionProfile(sampleX, maximumBand, outerGuard)
|
||||||
@@ -198,7 +223,7 @@ export function createGlassNavbarDisplacementField({
|
|||||||
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
const profile = distanceInside < bandWidth ? refractionProfile(distanceInside, bandWidth, outerGuard) : 0
|
||||||
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
const verticalProgress = Math.min(1, (distanceInside - outerGuard) / (bandWidth - outerGuard))
|
||||||
// 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。
|
// 侧栏的两轴必须共用同一深度剖面,圆角法线旋转时才不会变成扁平或椭圆透镜。
|
||||||
const verticalProfile = 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 verticalAmplitude = (bandWidth * optics.verticalRatio * 255) / HIGH_REFRACTION_SCALE_PX
|
||||||
const gradientX =
|
const gradientX =
|
||||||
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
roundedRectangleSignedDistance(sampleX + 0.5, sampleY, pixelWidth, pixelHeight, pixelRadius) -
|
||||||
@@ -220,9 +245,7 @@ export function createGlassNavbarDisplacementField({
|
|||||||
return { height: pixelHeight, pixels, width: pixelWidth }
|
return { height: pixelHeight, pixels, width: pixelWidth }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把位移场栅格化为浏览器可直接加载的无损 PNG。 */
|
function encodeDisplacementField(field: GlassNavbarDisplacementField) {
|
||||||
export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplacementGeometry) {
|
|
||||||
const field = createGlassNavbarDisplacementField(geometry)
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
@@ -238,6 +261,44 @@ export function createGlassNavbarDisplacementMap(geometry: GlassNavbarDisplaceme
|
|||||||
return canvas.toDataURL('image/png')
|
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 引擎启用实时顶栏折射。 */
|
/** 仅在已验证 SVG backdrop 位移的 Chromium 引擎启用实时顶栏折射。 */
|
||||||
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
|
export function supportsGlassNavbarLiveRefraction(browserIdentity: GlassNavbarRefractionBrowserIdentity = navigator) {
|
||||||
const brands = browserIdentity.userAgentData?.brands
|
const brands = browserIdentity.userAgentData?.brands
|
||||||
|
|||||||
Reference in New Issue
Block a user