Files
MoviePilot-Frontend/src/composables/useGlassOpticalRenderer.ts
2026-07-30 21:01:15 +08:00

4000 lines
154 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
import type {
BufferGeometry,
Color,
IUniform,
Mesh,
OrthographicCamera,
Scene,
ShaderMaterial,
Texture,
Vector2,
Vector4,
WebGLRenderTarget,
WebGLRenderer,
} from 'three'
import {
canUseGlassWallpaperTexture,
GLASS_OPTICAL_MAX_SURFACES_DESKTOP,
GLASS_OPTICAL_MAX_SURFACES_MOBILE,
GLASS_OPTICAL_STRENGTH_DEFAULT,
getGlassCoverScale,
getGlassMaterialResponse,
getGlassOpticalBufferSize,
getGlassOpticalDecay,
getGlassOpticalDeformationStrengthScale,
getGlassOpticalFlowStrengthScale,
getGlassOpticalMaxRefractionPixels,
getGlassOpticalMotionExpansion,
getGlassOpticalMotionEnergy,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransmissionStrength,
getGlassScrollBufferSize,
getGlassOpticalTranslationStrengthScale,
getGlassOpticalSurfaceTransitionWeights,
getGlassOpticalWakeDirection,
getGlassWallpaperTransitionProgress,
normalizeGlassOpticalRect,
reconcileGlassOpticalSurfaceSlots,
selectGlassOpticalRects,
type GlassCornerRadii,
type GlassOpticalQuality,
type GlassOpticalRect,
type GlassOpticalSurfaceCandidate,
type GlassOpticalSurfaceMode,
type GlassOpticalSurfaceSlot,
} from '@/utils/glassOptics'
import type { ThemeCustomizerGlassAppearance } from '@/composables/useThemeCustomizer'
import type { PagePresentationMotionReader } from '@/composables/usePagePresentationMotion'
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
import {
analyzeGlassWallpaperTone,
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
loadGlassWallpaperTone,
takeGlassWallpaperDecodedSource,
type GlassWallpaperToneProfile,
} from '@/utils/glassWallpaperTone'
export type GlassRendererState = 'fallback' | 'loading' | 'ready'
export type GlassPresentationSpace = 'fixed' | 'scroll'
type ThreeModule = typeof import('three')
export interface GlassOpticalInteractionSource {
/** 按呈现空间订阅共享输入;同一事件只会交给一个空间。 */
subscribe(space: GlassPresentationSpace, listener: (event: PointerEvent | TouchEvent) => void): () => void
}
export interface PreparedWallpaperSource {
/** 已完成 CPU 侧缩放的共享像素源;每个 WebGL context 仍独立创建纹理。 */
image: HTMLCanvasElement | HTMLImageElement
/** 共享像素源高度。 */
height: number
/** 与共享像素源同时计算的壁纸亮度 profile。 */
toneProfile: GlassWallpaperToneProfile
/** 共享像素源宽度。 */
width: number
}
/** fixed 与 scroll context 共用的有界 CPU 壁纸准备缓存。 */
export interface GlassWallpaperSourceCache {
/** 相同键共享进行中或已完成的准备结果,并将缓存限制在三个壁纸槽。 */
get(key: string, prepare: () => Promise<PreparedWallpaperSource>): Promise<PreparedWallpaperSource>
}
function recordGlassRendererTiming(space: GlassPresentationSpace, stage: string) {
const timingWindow = window as typeof window & {
__glassPerformanceProbeEnabled?: boolean
__glassRendererTimings?: Array<{ space: GlassPresentationSpace; stage: string; time: number }>
}
if (!import.meta.env.DEV || !timingWindow.__glassPerformanceProbeEnabled) return
timingWindow.__glassRendererTimings ??= []
timingWindow.__glassRendererTimings.push({ space, stage, time: performance.now() })
}
/** 复用 canvas context 前复位 Three.js 的 3D 占位纹理不接受的像素解包状态。 */
export function prepareGlassWebGLContext(canvas: HTMLCanvasElement) {
if (typeof WebGL2RenderingContext === 'undefined') return null
const context = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: true,
failIfMajorPerformanceCaveat: false,
powerPreference: 'high-performance',
premultipliedAlpha: true,
preserveDrawingBuffer: false,
stencil: false,
})
if (!context || context.isContextLost()) return null
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, false)
context.pixelStorei(context.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
return context
}
/** 为同一个光学层实例创建 CPU 壁纸源缓存GPU 纹理仍由各 context 独立管理。 */
export function createGlassWallpaperSourceCache(): GlassWallpaperSourceCache {
const entries = new Map<string, Promise<PreparedWallpaperSource>>()
return {
get(key, prepare) {
const cached = entries.get(key)
if (cached) {
entries.delete(key)
entries.set(key, cached)
return cached
}
const source = prepare()
entries.set(key, source)
while (entries.size > 3) {
const oldestKey = entries.keys().next().value
if (oldestKey) entries.delete(oldestKey)
}
void source.catch(() => {
if (entries.get(key) === source) entries.delete(key)
})
return source
},
}
}
/** 同步组件状态和根节点属性,确保 CSS 回退与 renderer 生命周期一致。 */
export function setGlassRendererState(state: Ref<GlassRendererState>, value: GlassRendererState) {
state.value = value
document.documentElement.dataset.glassRendererState = value
}
/** 为有界的多个呈现 context 建立唯一的全局指针与触摸事件源。 */
export function useGlassOpticalInteractionSource(): GlassOpticalInteractionSource {
const listeners: Record<GlassPresentationSpace, Set<(event: PointerEvent | TouchEvent) => void>> = {
fixed: new Set(),
scroll: new Set(),
}
const touchOwners = new Map<number, GlassPresentationSpace>()
const resolvePointOwner = (clientX: number, clientY: number): GlassPresentationSpace | null => {
// Overlay 只能通过 backdrop-filter 采样已合成的下层 UI壁纸 renderer 不接管其表面或输入。
if (document.querySelector('.v-overlay--active')) return null
const loginCard = document.querySelector<HTMLElement>('.login-card')
if (loginCard) {
const rect = loginCard.getBoundingClientRect()
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
// 登录卡片与文档弹性滚动共用 scroll-space输入必须落到同一呈现 context。
return document.querySelector('.login-root') ? 'scroll' : 'fixed'
}
}
const fixedSurface = document.querySelectorAll<HTMLElement>(
'.agent-assistant-panel, .layout-navbar, .layout-vertical-nav',
)
for (const surface of fixedSurface) {
const rect = surface.getBoundingClientRect()
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) return 'fixed'
}
return 'scroll'
}
const resolveTouchOwner = (event: TouchEvent) => {
const changedTouch = event.changedTouches.item(0)
if (!changedTouch) return null
if (event.type === 'touchstart') {
const owner = resolvePointOwner(changedTouch.clientX, changedTouch.clientY)
if (owner) touchOwners.set(changedTouch.identifier, owner)
return owner
}
return touchOwners.get(changedTouch.identifier) ?? null
}
const isTouchInteractionEvent = (event: PointerEvent | TouchEvent): event is TouchEvent =>
event.type.startsWith('touch')
const dispatch = (event: PointerEvent | TouchEvent) => {
const owner = isTouchInteractionEvent(event)
? resolveTouchOwner(event)
: resolvePointOwner(event.clientX, event.clientY)
if (!owner) return
for (const listener of listeners[owner]) listener(event)
if (isTouchInteractionEvent(event) && (event.type === 'touchend' || event.type === 'touchcancel')) {
for (const touch of Array.from(event.changedTouches)) touchOwners.delete(touch.identifier)
}
}
window.addEventListener('pointermove', dispatch, { passive: true })
window.addEventListener('touchstart', dispatch, { passive: true })
window.addEventListener('touchmove', dispatch, { passive: true })
window.addEventListener('touchend', dispatch, { passive: true })
window.addEventListener('touchcancel', dispatch, { passive: true })
onScopeDispose(() => {
listeners.fixed.clear()
listeners.scroll.clear()
touchOwners.clear()
window.removeEventListener('pointermove', dispatch)
window.removeEventListener('touchstart', dispatch)
window.removeEventListener('touchmove', dispatch)
window.removeEventListener('touchend', dispatch)
window.removeEventListener('touchcancel', dispatch)
})
return {
subscribe(space, listener) {
listeners[space].add(listener)
return () => listeners[space].delete(listener)
},
}
}
interface GlassRendererUniforms extends Record<string, IUniform> {
uAppearance: IUniform<number>
uBackgroundVisibility: IUniform<number>
uCoverScale: IUniform<Vector2>
uDeformationStrength: IUniform<number>
uDynamicsOnly: IUniform<number>
uFlowTexture: IUniform<Texture | null>
uFlowStrength: IUniform<number>
uHasWallpaperTexture: IUniform<number>
uHasFlowTexture: IUniform<number>
uHasFrostedTexture: IUniform<number>
uInteractionRadii: IUniform<Vector4[]>
uInteractionRectCount: IUniform<number>
uInteractionRects: IUniform<Vector4[]>
uMotion: IUniform<number>
uMotionExpansion: IUniform<number>
uMaxRefractionPixels: IUniform<number>
uPointer: IUniform<Vector2>
uPointerVelocity: IUniform<Vector2>
uPresentationSize: IUniform<Vector2>
uPreviousCoverScale: IUniform<Vector2>
uPreviousWallpaperExposure: IUniform<number>
uQuality: IUniform<number>
uReflectionStrength: IUniform<number>
uRadii: IUniform<Vector4[]>
uRectCount: IUniform<number>
uRects: IUniform<Vector4[]>
uSurfaceWeights: IUniform<number[]>
uSurfaceDynamics: IUniform<number[]>
uPreviousTexture: IUniform<Texture | null>
uPreviousFrostedTexture: IUniform<Texture | null>
uTexture: IUniform<Texture | null>
uFrostedTexture: IUniform<Texture | null>
uTextureMix: IUniform<number>
uWallpaperExposure: IUniform<number>
uTintColor: IUniform<Color>
uFrostDetailLevel: IUniform<number>
uSurfaceDensity: IUniform<number>
uTintDensity: IUniform<number>
uTransmissionStrength: IUniform<number>
uTranslationStrength: IUniform<number>
uTrail: IUniform<Vector4[]>
uTrailCount: IUniform<number>
uVisibleViewportSize: IUniform<Vector2>
uScrollOffset: IUniform<Vector2>
uWakeDirection: IUniform<Vector2>
}
interface GlassFlowUniforms extends Record<string, IUniform> {
uDecay: IUniform<number>
uInjection: IUniform<number>
uPointer: IUniform<Vector2>
uPrevious: IUniform<Texture | null>
uTexelSize: IUniform<Vector2>
uVelocity: IUniform<Vector2>
uViewportAspect: IUniform<number>
}
interface GlassRendererResources {
camera: OrthographicCamera
geometry: BufferGeometry
material: ShaderMaterial
mesh: Mesh
renderer: WebGLRenderer
scene: Scene
uniforms: GlassRendererUniforms
}
interface GlassFlowResources {
material: ShaderMaterial
mesh: Mesh
readTarget: WebGLRenderTarget
scene: Scene
uniforms: GlassFlowUniforms
writeTarget: WebGLRenderTarget
}
interface GlassFrostPrefilterResources {
material: ShaderMaterial
mesh: Mesh
scene: Scene
uniforms: {
uDirection: IUniform<Vector2>
uTexture: IUniform<Texture | null>
uTextureSize: IUniform<Vector2>
}
}
interface UseGlassOpticalRendererOptions {
active: MaybeRefOrGetter<boolean>
appearance: MaybeRefOrGetter<ThemeCustomizerGlassAppearance>
canvas: Ref<HTMLCanvasElement | null>
deformationStrength?: MaybeRefOrGetter<number>
flowStrength?: MaybeRefOrGetter<number>
interactionSource?: GlassOpticalInteractionSource
/** 旧调用方的单一动态强度仅作为三个独立维度的兼容回退。 */
motionStrength?: MaybeRefOrGetter<number>
/** scroll-space 页面表面与 DOM 共用的短时呈现状态fixed-space 不参与路由入场。 */
pageMotion?: PagePresentationMotionReader
quality: MaybeRefOrGetter<GlassOpticalQuality>
reflectionStrength?: MaybeRefOrGetter<number>
previousWallpaperUrl?: MaybeRefOrGetter<string>
/** 下一张壁纸可在提交切换前完成解码和 GPU 上传。 */
pendingWallpaperUrl?: MaybeRefOrGetter<string>
/** 父层壁纸事务版本,用于隔离相同 URL 的取消、迟到回执和后续请求。 */
pendingWallpaperRevision?: MaybeRefOrGetter<number>
transparencyStrength?: MaybeRefOrGetter<number>
routeKey: MaybeRefOrGetter<string>
syncDocumentState?: boolean
surfaceSpace?: GlassPresentationSpace
tintColor: MaybeRefOrGetter<string>
transitionDuration?: MaybeRefOrGetter<number>
transitionStartedAt?: MaybeRefOrGetter<number>
/** 玻璃内部壁纸采样的透射亮度。 */
transmissionStrength?: MaybeRefOrGetter<number>
translationStrength?: MaybeRefOrGetter<number>
wallpaperUrl: MaybeRefOrGetter<string>
/** 同一光学层的 fixed 与 scroll context 共享 CPU 侧壁纸准备结果。 */
wallpaperSourceCache?: GlassWallpaperSourceCache
}
type GlassOpticalSurfaceDescriptor = GlassOpticalSurfaceCandidate<HTMLElement> & {
mode: GlassOpticalSurfaceMode
}
interface GlassInteractionClipDescriptor extends GlassOpticalSurfaceDescriptor {
/** 拥有该局部裁剪的顶层材质面。 */
owner: HTMLElement
}
interface PreparedWallpaperTexture {
/** 单次预滤后的低分辨率壁纸;中间 RenderTarget 不跨准备阶段保留。 */
frostedTarget: WebGLRenderTarget | null
/** 是否包含可采样的真实壁纸,而非程序化回退纹理。 */
hasWallpaperTexture: boolean
/** 纹理像素高度。 */
height: number
/** 已完成当前 WebGL context 上传的纹理。 */
texture: Texture
/** 纹理自身的稳健整体曝光;双纹理过渡期间不可与另一张壁纸共用。 */
toneProfile: GlassWallpaperToneProfile
/** 资源创建时的材质、质量和纹理来源能力键。 */
preparationKey: string
/** 纹理像素宽度。 */
width: number
}
const SURFACE_SELECTORS = [
{ rank: 1, selector: '.agent-assistant-panel', space: 'fixed' },
{ rank: 1, selector: '.login-card', space: 'fixed' },
{ rank: 2, selector: '.layout-vertical-nav', space: 'fixed' },
{ rank: 2, selector: '.layout-navbar', space: 'fixed' },
{
rank: 3,
selector: '.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card',
space: 'scroll',
},
{
rank: 3,
selector:
'.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card',
space: 'scroll',
},
{ rank: 3, selector: '[data-glass-optical-surface]', space: 'scroll' },
// 推荐、订阅、媒体详情与设置页共用该交互卡片契约,不按业务路由维护 renderer 白名单。
{ rank: 4, selector: '.app-hover-lift-card', space: 'scroll' },
// 顶层业务卡片共享玻璃表面语义;嵌套卡片由表面收集阶段折叠,避免按页面维护白名单。
{ rank: 5, selector: '.layout-page-content .v-card', space: 'scroll' },
] as const
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
const INTERACTION_CLIP_OVERSCAN_PX = 96
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
function getSurfacePresentationSpace(
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
defaultSpace: GlassPresentationSpace,
) {
return selector === '.login-card' && document.querySelector('.login-root') ? 'scroll' : defaultSpace
}
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
export function containsGlassOpticalSurface(node: Node) {
if (!(node instanceof Element)) return false
if (node.matches(SURFACE_SELECTOR_QUERY) && !node.closest('.v-overlay')) return true
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(element => !element.closest('.v-overlay'))
}
const VERTEX_SHADER = `
varying vec2 vUv;
void main() {
vUv = position.xy * 0.5 + 0.5;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`
const DYNAMIC_RANGE_SCALE = 0.4
const DYNAMIC_RANGE_DENSITY = 1 / DYNAMIC_RANGE_SCALE ** 2
const FLOW_FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uPrevious;
uniform vec2 uPointer;
uniform vec2 uVelocity;
uniform vec2 uTexelSize;
uniform float uInjection;
uniform float uDecay;
uniform float uViewportAspect;
varying vec2 vUv;
void main() {
vec4 previous = (
texture2D(uPrevious, vUv) * 0.5 +
texture2D(uPrevious, vUv + vec2(uTexelSize.x, 0.0)) * 0.125 +
texture2D(uPrevious, vUv - vec2(uTexelSize.x, 0.0)) * 0.125 +
texture2D(uPrevious, vUv + vec2(0.0, uTexelSize.y)) * 0.125 +
texture2D(uPrevious, vUv - vec2(0.0, uTexelSize.y)) * 0.125
);
float previousEnergy = previous.z;
vec2 flow = previousEnergy < 0.001 ? vec2(0.0) : (previous.xy * 2.0 - 1.0) * uDecay;
float energy = previousEnergy * uDecay;
vec2 delta = vUv - uPointer;
delta.x *= uViewportAspect;
float distanceSquared = dot(delta, delta);
float injection = exp(-distanceSquared * ${(70 * DYNAMIC_RANGE_DENSITY).toFixed(3)}) * uInjection;
float speed = length(uVelocity);
vec2 direction = speed > 0.0001 ? uVelocity / speed : vec2(0.0, -1.0);
vec2 perpendicular = vec2(-direction.y, direction.x);
float shear = dot(delta, perpendicular) * exp(-distanceSquared * ${(42 * DYNAMIC_RANGE_DENSITY).toFixed(3)});
flow += (direction * min(speed * 9.0, 0.9) - perpendicular * shear * 0.85) * injection * 0.44;
energy = max(energy, injection);
gl_FragColor = vec4(flow * 0.5 + 0.5, energy, 1.0);
}
`
const FROST_PREFILTER_FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uTexture;
uniform vec2 uDirection;
uniform vec2 uTextureSize;
varying vec2 vUv;
void main() {
vec2 texel = uDirection / max(uTextureSize, vec2(1.0));
vec3 color =
texture2D(uTexture, vUv).rgb * 0.227027 +
texture2D(uTexture, vUv + texel * 1.384615).rgb * 0.316216 +
texture2D(uTexture, vUv - texel * 1.384615).rgb * 0.316216 +
texture2D(uTexture, vUv + texel * 3.230769).rgb * 0.070270 +
texture2D(uTexture, vUv - texel * 3.230769).rgb * 0.070270;
gl_FragColor = vec4(color, 1.0);
}
`
const FROST_PREFILTER_SCALE = 0.125
const WALLPAPER_SOURCE_LIMIT_BALANCED = 1536
const WALLPAPER_SOURCE_LIMIT_HIGH = 2048
/**
* 返回 GPU 壁纸 bundle 的稳定能力键。
* 透明与色调共享普通纹理能力,磨砂、质量和实际纹理来源会改变资源形态。
*/
export function getGlassWallpaperPreparationKey(
appearance: ThemeCustomizerGlassAppearance,
quality: GlassOpticalQuality,
routeKey: string,
url: string,
locationHref = window.location.href,
) {
const profile = getGlassOpticalRenderProfile(quality, routeKey)
const textureSource =
profile.textureSource === 'auto'
? canUseGlassWallpaperTexture(url, locationHref)
? 'wallpaper'
: 'procedural'
: profile.textureSource
const materialTexture = appearance === 'frosted' ? 'frosted' : 'plain'
return `${materialTexture}:${quality}:${profile.textureLimit}:${textureSource}`
}
const FRAGMENT_SHADER = `
precision highp float;
uniform sampler2D uPreviousTexture;
uniform sampler2D uTexture;
uniform sampler2D uPreviousFrostedTexture;
uniform sampler2D uFrostedTexture;
uniform sampler2D uFlowTexture;
uniform vec2 uCoverScale;
uniform vec2 uPreviousCoverScale;
uniform vec2 uPresentationSize;
uniform vec2 uScrollOffset;
uniform vec2 uVisibleViewportSize;
uniform float uDeformationStrength;
uniform float uDynamicsOnly;
uniform float uFlowStrength;
uniform float uHasWallpaperTexture;
uniform float uHasFlowTexture;
uniform float uHasFrostedTexture;
uniform vec4 uInteractionRadii[8];
uniform vec4 uInteractionRects[8];
uniform int uInteractionRectCount;
uniform float uMotion;
uniform float uMotionExpansion;
uniform float uMaxRefractionPixels;
uniform vec2 uPointer;
uniform vec2 uPointerVelocity;
uniform vec2 uWakeDirection;
uniform float uQuality;
uniform float uReflectionStrength;
uniform vec4 uRects[8];
uniform vec4 uRadii[8];
uniform float uSurfaceWeights[8];
uniform float uSurfaceDynamics[8];
uniform int uRectCount;
uniform float uAppearance;
uniform float uBackgroundVisibility;
uniform vec3 uTintColor;
uniform float uFrostDetailLevel;
uniform float uSurfaceDensity;
uniform float uTintDensity;
uniform float uTransmissionStrength;
uniform float uTranslationStrength;
uniform float uTextureMix;
uniform float uPreviousWallpaperExposure;
uniform float uWallpaperExposure;
uniform vec4 uTrail[4];
uniform int uTrailCount;
varying vec2 vUv;
float cornerRadius(vec2 centered, vec4 radii) {
if (centered.x >= 0.0) return centered.y >= 0.0 ? radii.y : radii.z;
return centered.y >= 0.0 ? radii.x : radii.w;
}
float roundedRectMask(vec2 local, vec2 rectSize, vec4 radii) {
vec2 localPixels = (local - 0.5) * rectSize;
float radius = cornerRadius(localPixels, radii);
float safeRadius = min(max(radius, 0.0), min(rectSize.x, rectSize.y) * 0.5);
vec2 centered = abs(localPixels) - (rectSize * 0.5 - vec2(safeRadius));
float distanceToEdge = length(max(centered, 0.0)) + min(max(centered.x, centered.y), 0.0) - safeRadius;
return 1.0 - smoothstep(0.0, 1.5, distanceToEdge);
}
vec2 coverUv(vec2 uv) {
vec2 documentPixels = vec2(uv.x, 1.0 - uv.y) * uPresentationSize;
vec2 viewportPixels = documentPixels - uScrollOffset;
vec2 viewportUv = vec2(
viewportPixels.x / max(uVisibleViewportSize.x, 1.0),
1.0 - viewportPixels.y / max(uVisibleViewportSize.y, 1.0)
);
return vec2(0.5) + (viewportUv - vec2(0.5)) * uCoverScale;
}
vec3 compressWallpaperLuminance(vec3 color, float wallpaperExposure) {
vec3 exposed = max(color * wallpaperExposure, vec3(0.0));
float sourceLuminance = dot(exposed, vec3(0.2126, 0.7152, 0.0722));
if (sourceLuminance <= 0.0001) return exposed;
float shadowLift = (1.0 - smoothstep(0.06, 0.5, sourceLuminance)) * 0.22;
float highlightCompression = smoothstep(0.72, 0.98, sourceLuminance) * 0.08;
float compressedLuminance =
sourceLuminance * (1.0 + shadowLift) * (1.0 - highlightCompression);
return clamp(exposed * (compressedLuminance / sourceLuminance), 0.0, 1.0);
}
vec3 toneMapWallpaper(vec3 color, vec2 uv, float wallpaperExposure) {
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float frosted = step(1.5, uAppearance);
float exposure = mix(0.86, 0.85, tinted);
exposure = mix(exposure, 0.82, frosted);
float saturation = mix(0.82, 0.95, tinted);
saturation = mix(saturation, 0.9, frosted);
float contrast = mix(1.02, 1.0, frosted);
vec3 normalized = compressWallpaperLuminance(color, wallpaperExposure);
float luminance = dot(normalized, vec3(0.2126, 0.7152, 0.0722));
vec3 mapped = mix(vec3(luminance), normalized, saturation);
mapped = clamp((mapped - vec3(0.5)) * contrast + vec3(0.5), 0.0, 1.0) * exposure;
float top = 1.0 - uv.y;
float linearStart = mix(0.1, 0.24, frosted);
float linearEnd = mix(mix(0.3, 0.32, tinted), 0.48, frosted);
float linearAbsorption = mix(linearStart, linearEnd, top);
vec2 radialDelta = (uv - vec2(0.5, 0.82)) / vec2(0.78, 1.0);
float radialAbsorption = smoothstep(0.24, 0.92, length(radialDelta)) * mix(0.12, 0.14, tinted);
radialAbsorption *= 1.0 - frosted;
vec3 absorbed = mapped * (1.0 - linearAbsorption) * (1.0 - radialAbsorption);
float transmissionResponse =
pow(clamp(uTransmissionStrength, 0.0, 1.0), mix(0.9, 0.78, uQuality));
float referenceLiftProgress = smoothstep(0.7, 1.0, uTransmissionStrength);
float highTransmissionProgress =
clamp((uTransmissionStrength - 1.0) / 0.3, 0.0, 1.0);
float transmissionMaterialScale = mix(1.0, 0.9, tinted);
float frostedTransparencyProgress =
smoothstep(0.6, 0.96, uBackgroundVisibility);
float frostedTransmissionScale = mix(0.42, 0.72, frostedTransparencyProgress);
transmissionMaterialScale = mix(transmissionMaterialScale, frostedTransmissionScale, frosted);
float highlightProtection = smoothstep(0.68, 0.92, luminance);
vec3 transmissionReference = normalized;
float referenceLift =
transmissionMaterialScale *
(
referenceLiftProgress * mix(0.18, 0.25, uQuality) +
highTransmissionProgress * mix(0.1, 0.16, uQuality)
);
transmissionReference *= 1.0 + referenceLift * (1.0 - highlightProtection * 0.72);
transmissionReference = min(transmissionReference, vec3(0.96));
vec3 protectedHighlightReference = min(normalized, vec3(0.96));
transmissionReference = mix(
transmissionReference,
protectedHighlightReference,
highlightProtection * 0.78
);
float transmissionMix = min(
transmissionResponse *
transmissionMaterialScale *
mix(0.58, 0.84, uQuality),
0.84
);
return mix(absorbed, transmissionReference, transmissionMix);
}
vec3 sampleWallpaper(vec2 uv) {
vec2 viewportUv = vec2(0.5) + (uv - vec2(0.5)) / max(uCoverScale, vec2(0.0001));
vec2 previousUv = vec2(0.5) + (viewportUv - vec2(0.5)) * uPreviousCoverScale;
vec3 previous;
vec3 current;
if (uAppearance > 1.5 && uHasFrostedTexture > 0.5) {
float frostLod = (1.0 - uFrostDetailLevel) * 6.0;
// 低分辨率预滤已经扩大了每个 texel 的原图 footprintLOD 只追加当前纹理内的低通层级。
float frostGradientScale = exp2(frostLod);
previous = texture2DGradEXT(
uPreviousFrostedTexture,
previousUv,
dFdx(previousUv) * frostGradientScale,
dFdy(previousUv) * frostGradientScale
).rgb;
current = texture2DGradEXT(
uFrostedTexture,
uv,
dFdx(uv) * frostGradientScale,
dFdy(uv) * frostGradientScale
).rgb;
} else {
previous = texture2D(uPreviousTexture, previousUv).rgb;
current = texture2D(uTexture, uv).rgb;
}
if (uTextureMix <= 0.001) {
return toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
}
if (uTextureMix >= 0.999) {
return toneMapWallpaper(current, viewportUv, uWallpaperExposure);
}
vec3 previousTone = toneMapWallpaper(previous, viewportUv, uPreviousWallpaperExposure);
vec3 currentTone = toneMapWallpaper(current, viewportUv, uWallpaperExposure);
return mix(previousTone, currentTone, uTextureMix);
}
vec3 sampleChromatic(vec2 uv, float separation) {
return vec3(
sampleWallpaper(uv + vec2(separation, 0.0)).r,
sampleWallpaper(uv).g,
sampleWallpaper(uv - vec2(separation, 0.0)).b
);
}
vec3 sampleBalancedDiffuse(vec2 uv, vec2 axis, float radius) {
vec2 firstOffset = axis * radius;
vec2 secondOffset = vec2(-axis.y, axis.x) * radius;
return (
sampleWallpaper(uv) * 0.28 +
sampleWallpaper(uv + firstOffset) * 0.18 +
sampleWallpaper(uv - firstOffset) * 0.18 +
sampleWallpaper(uv + secondOffset) * 0.18 +
sampleWallpaper(uv - secondOffset) * 0.18
);
}
vec3 sampleHighQualityDiffuse(vec2 uv, vec2 axis, float radius) {
vec2 firstOffset = axis * radius;
vec2 secondOffset = vec2(-axis.y, axis.x) * radius;
vec2 firstDiagonal = normalize(firstOffset + secondOffset) * radius * 1.15;
vec2 secondDiagonal = normalize(firstOffset - secondOffset) * radius * 1.15;
return (
sampleWallpaper(uv) * 0.2 +
sampleWallpaper(uv + firstOffset) * 0.12 +
sampleWallpaper(uv - firstOffset) * 0.12 +
sampleWallpaper(uv + secondOffset) * 0.12 +
sampleWallpaper(uv - secondOffset) * 0.12 +
sampleWallpaper(uv + firstDiagonal) * 0.08 +
sampleWallpaper(uv - firstDiagonal) * 0.08 +
sampleWallpaper(uv + secondDiagonal) * 0.08 +
sampleWallpaper(uv - secondDiagonal) * 0.08
);
}
float getContentProtection(vec2 sourceUv) {
if (uQuality < 0.5 || uHasWallpaperTexture < 0.5 || uAppearance > 1.5) return 1.0;
vec2 sourceTexel = max(uCoverScale, vec2(0.0001)) / max(uVisibleViewportSize, vec2(1.0));
vec3 horizontalStart = sampleWallpaper(sourceUv - vec2(sourceTexel.x * 2.5, 0.0));
vec3 horizontalEnd = sampleWallpaper(sourceUv + vec2(sourceTexel.x * 2.5, 0.0));
vec3 verticalStart = sampleWallpaper(sourceUv - vec2(0.0, sourceTexel.y * 2.5));
vec3 verticalEnd = sampleWallpaper(sourceUv + vec2(0.0, sourceTexel.y * 2.5));
float contentGradient = max(length(horizontalEnd - horizontalStart), length(verticalEnd - verticalStart));
return mix(1.0, 0.44, smoothstep(0.07, 0.34, contentGradient));
}
vec2 softLimitDynamicRefraction(vec2 refraction) {
vec2 presentation = max(uPresentationSize, vec2(1.0));
vec2 refractionPixels = refraction * presentation;
float limit = max(0.5, uMaxRefractionPixels);
float limitScale = limit / sqrt(limit * limit + dot(refractionPixels, refractionPixels));
return refractionPixels * limitScale / presentation;
}
void main() {
float mask = 0.0;
float edge = 0.0;
float caustic = 0.0;
float directionalReflection = 0.0;
float topPrism = 0.0;
float backlightAbsorption = 0.0;
float materialEnergy = 0.0;
float sharedMotionPresence = 0.0;
float dynamicMask = 0.0;
vec2 staticRefraction = vec2(0.0);
vec2 dynamicRefraction = vec2(0.0);
vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0);
vec2 wakePerpendicular = vec2(-wakeDirection.y, wakeDirection.x);
vec2 trailRefraction = vec2(0.0);
float trailEnergy = 0.0;
float trailSpatialSpan = 0.0;
float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion);
const float dynamicRangeScale = ${DYNAMIC_RANGE_SCALE.toFixed(2)};
const float dynamicRangeDensity = ${DYNAMIC_RANGE_DENSITY.toFixed(3)};
float interactionMask = uInteractionRectCount > 0 ? 0.0 : 1.0;
for (int interactionIndex = 0; interactionIndex < 8; interactionIndex++) {
if (interactionIndex >= uInteractionRectCount) break;
vec4 interactionRect = uInteractionRects[interactionIndex];
vec2 interactionLocal = (vUv - interactionRect.xy) / max(interactionRect.zw, vec2(0.0001));
interactionMask = max(
interactionMask,
roundedRectMask(
interactionLocal,
interactionRect.zw * uPresentationSize,
uInteractionRadii[interactionIndex]
)
);
}
for (int trailIndex = 0; trailIndex < 4; trailIndex++) {
if (trailIndex >= uTrailCount) break;
vec4 trail = uTrail[trailIndex];
vec2 trailDelta = vUv - trail.xy;
trailDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
vec2 trailSpanDelta = trail.xy - uPointer;
trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z);
float along = dot(trailDelta, wakeDirection);
float across = dot(trailDelta, wakePerpendicular);
float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity;
float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion) * dynamicRangeDensity;
float lobe =
exp(-(along * along * trailAlongDensity + across * across * trailAcrossDensity)) * trail.z * uMotion;
float wake = mix(0.88, 0.58, float(trailIndex) / 3.0);
trailRefraction +=
(wakeDirection * 0.0048 + wakePerpendicular * across * 0.018) *
lobe *
uDeformationStrength *
uFlowStrength;
trailEnergy += lobe * wake * mix(0.72, 0.42, float(trailIndex) / 3.0);
}
vec4 flowSample = uHasFlowTexture > 0.5 ? texture2D(uFlowTexture, vUv) : vec4(0.5, 0.5, 0.0, 1.0);
vec2 temporalFlow =
uHasFlowTexture > 0.5
? (flowSample.xy * 2.0 - 1.0) *
flowSample.z *
uMotion *
uDeformationStrength *
uFlowStrength
: vec2(0.0);
float flowSurfaceDetail = 0.0;
if (uQuality > 0.5 && uHasFlowTexture > 0.5) {
vec2 flowTexel = vec2(3.0) / max(uPresentationSize, vec2(1.0));
vec3 flowLeft = texture2D(uFlowTexture, vUv - vec2(flowTexel.x, 0.0)).xyz;
vec3 flowRight = texture2D(uFlowTexture, vUv + vec2(flowTexel.x, 0.0)).xyz;
vec3 flowBottom = texture2D(uFlowTexture, vUv - vec2(0.0, flowTexel.y)).xyz;
vec3 flowTop = texture2D(uFlowTexture, vUv + vec2(0.0, flowTexel.y)).xyz;
float flowGradient = length(flowRight.xy - flowLeft.xy) + length(flowTop.xy - flowBottom.xy);
float energyGradient = abs(flowRight.z - flowLeft.z) + abs(flowTop.z - flowBottom.z);
flowSurfaceDetail = smoothstep(0.015, 0.24, flowGradient + energyGradient * 0.72) * uMotion;
}
float frosted = step(1.5, uAppearance);
for (int i = 0; i < 8; i++) {
if (i >= uRectCount) break;
vec4 rect = uRects[i];
float surfaceDynamic = uSurfaceDynamics[i];
vec2 local = (vUv - rect.xy) / rect.zw;
float rectMask = roundedRectMask(local, rect.zw * uPresentationSize, uRadii[i]) * uSurfaceWeights[i];
if (rectMask <= 0.0) continue;
float sideDistance = min(local.x, 1.0 - local.x);
float nonBottomDistance = min(sideDistance, 1.0 - local.y);
float edgeResponse = 1.0 - smoothstep(0.0, 0.16, nonBottomDistance);
vec2 lens = local - vec2(0.5);
vec2 lightDirection = normalize(vec2(-0.68, 0.74));
float lightCoordinate = dot(lens, lightDirection);
float broadLight =
exp(-pow((lightCoordinate - 0.08) * 2.25, 2.0)) *
(0.42 + edgeResponse * 0.58);
float topEdge = 1.0 - smoothstep(0.0, 0.11, 1.0 - local.y);
float rightEdge = 1.0 - smoothstep(0.0, 0.14, 1.0 - local.x);
float bottomEdge = 1.0 - smoothstep(0.0, 0.14, local.y);
float litResponse = clamp(0.5 + lightCoordinate * 1.15, 0.0, 1.0);
float localDirectionalReflection = broadLight * mix(0.34, 1.0, litResponse) * rectMask;
float localTopPrism = topEdge * mix(0.38, 1.0, 1.0 - local.x) * rectMask;
float localBacklightAbsorption = max(rightEdge * 0.72, bottomEdge * 0.46) * rectMask;
vec2 pointerDelta = uPointer - vUv;
vec2 pointerDeltaAspect = pointerDelta;
pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
// 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。
float pointerSpread = mix(26.0, 17.0, uQuality);
pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion);
float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan);
float pointerAlong = dot(-pointerDeltaAspect, wakeDirection);
float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular);
float sharedWakeTravel =
0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion);
float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread);
float directionalPointerShape =
exp(-(
pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 +
pointerAcross * pointerAcross * pointerSpread * 1.35
));
float pointerEnergy =
clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0);
float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion);
float radialSharedWave =
exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity);
float directionalSharedWave =
exp(-(
pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 +
pointerAcross * pointerAcross * sharedWaveDensity * 2.2
));
float sharedWaveEnergy =
mix(radialSharedWave, directionalSharedWave, sharedDirectionality) *
clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) *
mix(1.0, 0.78, sharedDirectionality) *
uMotion *
uMotion;
vec2 wakeDelta = vUv - uPointer;
wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
float wakeAlong = dot(wakeDelta, wakeDirection);
float wakeAcross = dot(wakeDelta, wakePerpendicular);
float wakeTravel =
0.014 * dynamicRangeScale *
mix(0.82, 1.18, uQuality) *
mix(1.0, 1.45, uMotionExpansion);
float wakeWidth =
mix(0.027, 0.044, uQuality) * dynamicRangeScale * mix(1.0, 1.72, uMotionExpansion);
float wakeCoordinate = (wakeAlong + wakeTravel) / wakeWidth;
float wakeShape = wakeCoordinate * exp(-0.5 * wakeCoordinate * wakeCoordinate);
float wakeEnvelope =
exp(
-wakeAcross *
wakeAcross *
mix(280.0, 145.0, uQuality) *
dynamicRangeDensity *
mix(1.0, 0.44, uMotionExpansion)
);
vec2 wakeRefraction =
wakeDirection *
wakeShape *
wakeEnvelope *
mix(0.0045, 0.0075, uQuality) *
uMotion *
uDeformationStrength *
uFlowStrength;
float wakeEnergy = abs(wakeShape) * wakeEnvelope * uMotion;
float liquidEnergy = clamp(max(
pointerEnergy,
max(min(1.0, trailEnergy) * 0.68, wakeEnergy * 0.82)
), 0.0, 1.0);
float staticLens = 0.00008 + edgeResponse * mix(0.00045, 0.00072, uQuality);
float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted);
float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted);
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
vec2 specularDelta =
vUv - (uPointer - wakeDirection * mix(0.006, 0.022, uMotionExpansion) * dynamicRangeScale);
specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
float specularAlong = dot(specularDelta, wakeDirection);
float specularAcross = dot(specularDelta, wakePerpendicular);
float singleSpecular =
exp(-(
specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) * dynamicRangeDensity +
specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) * dynamicRangeDensity
)) *
uMotion *
mix(1.0, 1.24, uMotionExpansion);
float localCaustic = singleSpecular * rectMask * surfaceDynamic * interactionMask;
staticRefraction += lens * staticLens * mix(1.0, 0.72, frosted) * rectMask * surfaceDynamic;
vec2 sampleTranslation =
uPointerVelocity *
mix(0.055, 0.075, uQuality) *
uMotion *
uTranslationStrength;
dynamicRefraction += (
sampleTranslation +
// 收紧高斯半径时补偿向量峰值,避免范围缩小同时削弱用户设置的形变强度。
pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength / dynamicRangeScale +
trailRefraction * trailStrength +
temporalFlow * temporalStrength +
wakeRefraction
) * rectMask * surfaceDynamic * interactionMask;
edge = max(edge, edgeResponse * rectMask * surfaceDynamic);
caustic = max(caustic, localCaustic);
directionalReflection = max(directionalReflection, localDirectionalReflection);
topPrism = max(topPrism, localTopPrism);
backlightAbsorption = max(backlightAbsorption, localBacklightAbsorption);
materialEnergy = max(materialEnergy, liquidEnergy * rectMask * surfaceDynamic * interactionMask);
sharedMotionPresence = max(
sharedMotionPresence,
sharedWaveEnergy * rectMask * surfaceDynamic * interactionMask
);
dynamicMask = max(dynamicMask, rectMask * surfaceDynamic * interactionMask);
mask = max(mask, rectMask);
}
if (mask <= 0.0) discard;
float contentProtection = getContentProtection(coverUv(vUv + staticRefraction));
dynamicRefraction *= contentProtection;
// 高光足迹与壁纸位移强度独立校准,收紧反馈范围不能同步削弱三项动态参数。
dynamicRefraction *= 1.2;
dynamicRefraction = softLimitDynamicRefraction(dynamicRefraction);
vec2 refraction = staticRefraction + dynamicRefraction;
vec2 sourceUv = coverUv(vUv + refraction);
float separation = edge * mix(0.00024, 0.00055, uQuality) * mix(1.0, 0.58, frosted);
float usesPrefilteredFrost = frosted * uHasFrostedTexture;
vec3 refracted = usesPrefilteredFrost > 0.5
? sampleWallpaper(sourceUv)
: sampleChromatic(sourceUv, separation);
float detailSeparation = separation * mix(1.45, 2.35, uQuality);
vec3 detailed = usesPrefilteredFrost > 0.5
? refracted
: sampleChromatic(sourceUv, detailSeparation);
refracted = mix(refracted, detailed, mix(0.06, 0.16, uQuality) * (1.0 - frosted));
vec2 diffusionAxis = length(refraction) > 0.00001 ? normalize(refraction) : wakePerpendicular;
float diffusionRadius =
mix(0.0022, 0.0038, uQuality) *
(
0.82 +
materialEnergy * mix(0.28, 0.76, uMotionExpansion) +
flowSurfaceDetail * dynamicMask * 0.38
);
float frostedDensity = frosted * (1.0 - uFrostDetailLevel);
diffusionRadius *= 1.0 + frostedDensity * mix(1.15, 1.55, uQuality);
vec3 diffused;
if (usesPrefilteredFrost > 0.5) {
diffused = refracted;
} else if (uQuality > 0.5) {
diffused = sampleHighQualityDiffuse(sourceUv, diffusionAxis, diffusionRadius);
} else {
diffused = sampleBalancedDiffuse(sourceUv, diffusionAxis, diffusionRadius);
}
refracted = mix(refracted, diffused, frosted);
float refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float tinted = step(0.5, uAppearance) * (1.0 - step(1.5, uAppearance));
float transmissionOffset = min(uTransmissionStrength - 1.0, 0.0);
if (transmissionOffset < 0.0) {
float dimming = mix(0.14, 0.18, uQuality) * mix(1.0, 0.65, frosted);
refracted *= 1.0 + transmissionOffset * dimming;
}
refractedLuminance = dot(refracted, vec3(0.2126, 0.7152, 0.0722));
float highlightBudget = mix(1.0, 0.34, smoothstep(0.48, 0.9, refractedLuminance));
float frostedBrightCompression = smoothstep(0.58, 0.94, refractedLuminance) * frosted;
refracted *= 1.0 - frostedBrightCompression * mix(0.16, 0.22, uQuality);
vec3 frostedContrast = clamp((refracted - vec3(0.5)) * 1.16 + vec3(0.5), 0.0, 1.0);
refracted = mix(
refracted,
frostedContrast,
frosted * materialEnergy * mix(0.24, 0.58, uMotionExpansion)
);
vec3 highlight = vec3(0.84, 0.92, 1.0);
float edgeHighlightMix = 0.12;
float causticHighlightMix = 0.075;
float liquidPresence = clamp(materialEnergy, 0.0, 1.0);
float clearVisibilityProgress = clamp((uBackgroundVisibility - 0.18) / 0.78, 0.0, 1.0);
float clearBaseAlpha = mix(0.08, 0.34, clearVisibilityProgress);
float materialAlpha = clearBaseAlpha * mix(1.0, 2.5, liquidPresence);
float proceduralEdgeAlpha = 0.14;
float proceduralCausticAlpha = 0.075;
if (uAppearance > 1.5) {
highlight = vec3(0.94, 0.97, 1.0);
edgeHighlightMix = 0.15;
causticHighlightMix = 0.042;
float frostedBaseAlpha = mix(0.46, 0.88, uSurfaceDensity);
materialAlpha = frostedBaseAlpha * mix(0.9, 1.0, liquidPresence);
proceduralEdgeAlpha = 0.16;
proceduralCausticAlpha = 0.045;
} else if (uAppearance > 0.5) {
highlight = mix(vec3(1.0), uTintColor, mix(0.28, 0.72, uTintDensity));
edgeHighlightMix = 0.17;
causticHighlightMix = 0.085;
float tintedVisibilityProgress = clamp((uBackgroundVisibility - 0.08) / 0.84, 0.0, 1.0);
float tintedBaseAlpha = mix(0.16, 0.5, tintedVisibilityProgress);
materialAlpha = tintedBaseAlpha * mix(1.0, 1.8, liquidPresence);
}
if (uHasWallpaperTexture < 0.5) {
float proceduralReflection =
(directionalReflection * 0.42 + topPrism * 0.66 + caustic * 0.72) *
uReflectionStrength;
vec3 proceduralHighlight = highlight * proceduralReflection;
float proceduralAlpha =
mask *
(
directionalReflection * proceduralEdgeAlpha +
topPrism * proceduralEdgeAlpha * 0.76 +
caustic * proceduralCausticAlpha
) *
uReflectionStrength;
gl_FragColor = vec4(proceduralHighlight, proceduralAlpha);
return;
}
float reflectionMix =
clamp(
(directionalReflection * edgeHighlightMix + topPrism * edgeHighlightMix * 0.82) *
uReflectionStrength *
highlightBudget,
0.0,
0.36
);
float absorption =
clamp(backlightAbsorption * mix(0.035, 0.075, frosted) * uReflectionStrength, 0.0, 0.14);
refracted *= 1.0 - absorption;
refracted = mix(refracted, highlight, reflectionMix);
refracted += highlight * caustic * causticHighlightMix * uReflectionStrength * highlightBudget;
if (uDynamicsOnly > 0.5) {
float dynamicsPresence = max(materialEnergy, sharedMotionPresence * 0.36);
float dynamicsAlpha =
clamp(dynamicsPresence * mix(0.5, 0.72, uQuality) * mix(1.0, 1.12, frosted), 0.0, 0.82);
gl_FragColor = vec4(refracted, dynamicsAlpha);
return;
}
gl_FragColor = vec4(
refracted,
clamp(
mask *
(
materialAlpha +
(
directionalReflection * 0.065 +
topPrism * 0.05 +
caustic * mix(0.028, 0.04, uQuality)
) *
uReflectionStrength *
highlightBudget
),
0.0,
0.94
)
);
}
`
const SCROLL_STABLE_TAIL_FRAMES = 2
const PRESENTATION_RESIZE_SAMPLE_MS = 80
const PRESENTATION_RESIZE_REQUIRED_SAMPLES = 2
const SURFACE_STABILITY_MAX_FRAMES = 6
const SURFACE_STABILITY_REQUIRED_FRAMES = 2
const FLOW_BUFFER_SCALE = 0.25
const SURFACE_TRANSITION_DURATION_MS = 96
const SURFACE_TRANSFORM_TRACKING_MAX_MS = 1000
/** 按 shader 协议读取视觉表面的四角圆角。 */
function readBorderRadii(element: HTMLElement) {
const style = getComputedStyle(element)
const parseRadius = (value: string) => {
const radius = Number.parseFloat(value)
return Number.isFinite(radius) ? radius : 0
}
return [
parseRadius(style.borderTopLeftRadius),
parseRadius(style.borderTopRightRadius),
parseRadius(style.borderBottomRightRadius),
parseRadius(style.borderBottomLeftRadius),
] as const
}
/** 判断元素是否在布局和视口中实际可见。 */
function isVisibleSurface(element: HTMLElement, bounds: DOMRect) {
const style = getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number.parseFloat(style.opacity || '1') > 0 &&
bounds.width >= 24 &&
bounds.height >= 24
)
}
/** 从最近的组件边界读取模式;未声明的现有表面保持完整动态行为。 */
export function resolveGlassOpticalSurfaceMode(element: HTMLElement): GlassOpticalSurfaceMode {
const value = element.closest<HTMLElement>('[data-glass-optical-mode]')?.dataset.glassOpticalMode
return value === 'static-material' ? 'static-material' : 'dynamic'
}
/** 读取全部可见视觉表面,并保留 DOM 元素作为 renderer 生命周期内的稳定身份。 */
function collectGlassOpticalSurfaceDescriptors(
viewportWidth: number,
viewportHeight: number,
appearance: ThemeCustomizerGlassAppearance,
surfaceSpace: GlassPresentationSpace | 'all' = 'all',
includeOutsideViewport = false,
collectedElements?: HTMLElement[],
) {
const candidates: Array<GlassOpticalSurfaceDescriptor & { visibleArea: number }> = []
const seen = new Set<HTMLElement>()
for (const { rank, selector, space } of SURFACE_SELECTORS) {
const resolvedSpace = getSurfacePresentationSpace(selector, space)
if (surfaceSpace !== 'all' && resolvedSpace !== surfaceSpace) continue
// 移动端透明顶栏只使用稳定的 CSS 表面,避免滚动重扫时再次叠加壁纸折射。
if (viewportWidth <= 600 && appearance === 'clear' && selector === '.layout-navbar') continue
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
if (seen.has(element)) continue
seen.add(element)
if (element.closest('.v-overlay')) continue
collectedElements?.push(element)
const bounds = element.getBoundingClientRect()
if (!isVisibleSurface(element, bounds)) continue
const left = Math.max(0, bounds.left)
const top = Math.max(0, bounds.top)
const right = Math.min(viewportWidth, bounds.right)
const bottom = Math.min(viewportHeight, bounds.bottom)
const visibleHeight = Math.max(0, bottom - top)
const visibleWidth = Math.max(0, right - left)
if (!includeOutsideViewport && (visibleWidth < 24 || visibleHeight < 24)) continue
const coordinateOffsetX = resolvedSpace === 'scroll' ? window.scrollX : 0
const coordinateOffsetY = resolvedSpace === 'scroll' ? window.scrollY : 0
candidates.push({
key: element,
mode: resolveGlassOpticalSurfaceMode(element),
rect: {
height: bounds.height,
radii: [...readBorderRadii(element)] as GlassCornerRadii,
rank: rank + candidates.length * 0.001,
width: bounds.width,
x: bounds.left + coordinateOffsetX,
y: bounds.top + coordinateOffsetY,
},
visibleArea: visibleWidth * visibleHeight,
})
}
}
const selected: GlassOpticalSurfaceDescriptor[] = []
for (const candidate of candidates.sort((left, right) => {
// 祖先材质面必须先建立;后续子卡才能折叠为局部交互裁剪,而不是额外占用 shader slot。
if (left.key !== right.key) {
if (left.key.contains(right.key)) return -1
if (right.key.contains(left.key)) return 1
}
return left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea
})) {
const { rect } = candidate
const nested = selected.some(
parent =>
parent.mode === candidate.mode &&
rect.x >= parent.rect.x &&
rect.y >= parent.rect.y &&
rect.x + rect.width <= parent.rect.x + parent.rect.width &&
rect.y + rect.height <= parent.rect.y + parent.rect.height,
)
if (!nested) selected.push({ key: candidate.key, mode: candidate.mode, rect })
}
return selected
}
/** 从已测量的 presentation 坐标中选择当前视口可见表面,不触发 DOM 布局读取。 */
function selectVisibleGlassOpticalSurfaceDescriptors(
surfaces: GlassOpticalSurfaceDescriptor[],
viewportWidth: number,
viewportHeight: number,
surfaceSpace: GlassPresentationSpace,
) {
const viewportX = surfaceSpace === 'scroll' ? window.scrollX : 0
const viewportY = surfaceSpace === 'scroll' ? window.scrollY : 0
return surfaces.filter(({ rect }) => {
const visibleWidth = Math.min(viewportX + viewportWidth, rect.x + rect.width) - Math.max(viewportX, rect.x)
const visibleHeight = Math.min(viewportY + viewportHeight, rect.y + rect.height) - Math.max(viewportY, rect.y)
return visibleWidth >= 24 && visibleHeight >= 24
})
}
/** 将活动界面中的高价值材质面集中转换为 renderer 矩形预算。 */
export function collectGlassOpticalRects(
viewportWidth: number,
viewportHeight: number,
appearance: ThemeCustomizerGlassAppearance,
interactionPoint?: { x: number; y: number },
surfaceSpace: GlassPresentationSpace | 'all' = 'all',
) {
const candidates = collectGlassOpticalSurfaceDescriptors(viewportWidth, viewportHeight, appearance, surfaceSpace)
return selectGlassOpticalRects(
candidates.map(candidate => candidate.rect),
viewportWidth,
viewportHeight,
viewportWidth <= 600,
interactionPoint,
)
}
/** 将材质语义映射为稳定 shader 协议,质量档不参与该映射。 */
function getGlassAppearanceUniformValue(appearance: ThemeCustomizerGlassAppearance) {
if (appearance === 'tinted') return 1
if (appearance === 'frosted') return 2
return 0
}
/** 管理玻璃主题唯一 renderer、事件驱动帧调度与完整 GPU 资源释放。 */
export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions) {
const state = ref<GlassRendererState>('loading')
const renderedFrames = ref(0)
const activeWallpaperUrl = ref('')
const activeWallpaperRevision = ref(0)
const activeWallpaperPreparationKey = ref('')
const preparedWallpaperUrl = ref('')
const preparedWallpaperRevision = ref(0)
const preparedWallpaperPreparationKey = ref('')
const failedWallpaperUrl = ref('')
const failedWallpaperRevision = ref(0)
const failedWallpaperPreparationKey = ref('')
let three: ThreeModule | null = null
let resources: GlassRendererResources | null = null
let flowResources: GlassFlowResources | null = null
let frostPrefilterResources: GlassFrostPrefilterResources | null = null
let activeTexture: Texture | null = null
let activeFrostedTarget: WebGLRenderTarget | null = null
let activeTextureHeight = 1
let activeTextureWidth = 1
let activeHasWallpaperTexture = false
let activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
let previousTexture: Texture | null = null
let previousFrostedTarget: WebGLRenderTarget | null = null
let previousTextureHeight = 1
let previousTextureWidth = 1
let previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
let wallpaperTransitionStartedAt = 0
let wallpaperActivationRollback: {
activatedRevision: number
activatedUrl: string
previousHasWallpaperTexture: boolean
previousPreparationKey: string
previousRevision: number
previousUrl: string
} | null = null
let loadVersion = 0
let prepareVersion = 0
let preparedWallpaper: PreparedWallpaperTexture | null = null
let animationFrame: number | null = null
let wallpaperTransitionFrame: number | null = null
let backgroundDisposeTimer: number | null = null
let presentationBufferHeight = 1
let presentationBufferWidth = 1
let surfaceUpdateFrame: number | null = null
let surfaceStabilityFrame: number | null = null
let surfaceStabilityPass = 0
let surfaceStableFrameCount = 0
let lastSurfaceGeometrySignature = ''
let lastInteractionAt = 0
let lastInteractionFrameAt = 0
let lastPointerAt = 0
let lastTrailAt = Number.NEGATIVE_INFINITY
let lastPointerX = window.innerWidth * 0.5
let lastPointerY = window.innerHeight * 0.5
let pointerTargetX = 0.5
let pointerTargetY = 0.5
let pointerPositionX = 0.5
let pointerPositionY = 0.5
let activeTouchIdentifier: number | null = null
let pendingFlowInjection = 0
let interactionAnimating = false
let presentationResizeCandidate = ''
let presentationResizeStableSamples = 0
let presentationResizeTimer: number | null = null
let scrollAnimationFrame: number | null = null
let scrollDirty = false
let scrollPresentationRestoreTimer: number | null = null
let scrollWallpaperSamplingSuppressed = false
let scrollFrameCommitted = false
let scrollGeometryRefreshPending = false
let scrollLateGeometryCommitted = false
let scrollSurfaceStabilityPending = false
let scrollStableFrameCount = 0
let lastRenderedScrollX = window.scrollX
let lastRenderedScrollY = window.scrollY
let unsubscribeInteractionSource: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
let surfaceMutationObserver: MutationObserver | null = null
let observedSurfaces: HTMLElement[] = []
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
let interactionClipMembershipDirty = true
let activeSurface: HTMLElement | null = null
let activeInteractionClip: HTMLElement | null = null
let outgoingSurface: HTMLElement | null = null
let surfaceTransitionStartedAt = 0
let surfaceTransformFrame: number | null = null
let surfaceTransformTrackingDeadline = 0
const transformingSurfaces = new Set<HTMLElement>()
let pagePresentationGeometryReady = true
let wakeDirection = { x: 0, y: -1 }
let contextRecoveryPending = false
let resumePromise: Promise<void> | null = null
let resumeVersion = 0
const presentationSpace = options.surfaceSpace ?? 'fixed'
const wallpaperSourceCache = options.wallpaperSourceCache ?? createGlassWallpaperSourceCache()
/** 滚动期间由原生 backdrop 接管壁纸;稳定态恢复完整纹理折射与流体反馈。 */
function syncWallpaperSamplingMode() {
if (!resources) return
resources.uniforms.uHasWallpaperTexture.value =
activeHasWallpaperTexture && !(presentationSpace === 'scroll' && scrollWallpaperSamplingSuppressed) ? 1 : 0
}
function clearScrollPresentationRestoreTimer() {
if (scrollPresentationRestoreTimer === null) return
window.clearTimeout(scrollPresentationRestoreTimer)
scrollPresentationRestoreTimer = null
}
function finishNativeScrollPresentation(timestamp = performance.now()) {
clearScrollPresentationRestoreTimer()
if (presentationSpace !== 'scroll' || !scrollWallpaperSamplingSuppressed) return
scrollWallpaperSamplingSuppressed = false
syncWallpaperSamplingMode()
renderFrame(timestamp, false)
document.documentElement.removeAttribute('data-glass-scroll-presentation')
}
function beginNativeScrollPresentation() {
if (presentationSpace !== 'scroll' || !resources) return
clearScrollPresentationRestoreTimer()
scrollPresentationRestoreTimer = window.setTimeout(() => finishNativeScrollPresentation(), 180)
if (scrollWallpaperSamplingSuppressed) return
scrollWallpaperSamplingSuppressed = true
syncWallpaperSamplingMode()
document.documentElement.dataset.glassScrollPresentation = 'native'
renderFrame(performance.now(), false)
}
/** 找到本次输入实际可能推动的最近滚动容器。 */
function resolveScrollIntentTarget(event: Event): Element | null {
const documentScroller = document.scrollingElement ?? document.documentElement
const eventTarget = event.target
if (!(eventTarget instanceof Element)) return documentScroller
if (eventTarget.closest('.v-overlay')) return null
const wheel = event instanceof WheelEvent ? event : null
for (
let candidate: Element | null = eventTarget;
candidate && candidate !== document.body;
candidate = candidate.parentElement
) {
const style = getComputedStyle(candidate)
const canScrollY =
/(auto|overlay|scroll)/.test(style.overflowY) &&
candidate.scrollHeight > candidate.clientHeight &&
(!wheel ||
(wheel.deltaY < 0 && candidate.scrollTop > 0) ||
(wheel.deltaY > 0 && candidate.scrollTop + candidate.clientHeight < candidate.scrollHeight))
const canScrollX =
/(auto|overlay|scroll)/.test(style.overflowX) &&
candidate.scrollWidth > candidate.clientWidth &&
(!wheel ||
(wheel.deltaX < 0 && candidate.scrollLeft > 0) ||
(wheel.deltaX > 0 && candidate.scrollLeft + candidate.clientWidth < candidate.scrollWidth))
if (canScrollX || canScrollY) return candidate
}
return documentScroller
}
/** 只有会移动文档或已管理玻璃表面的滚动才需要切换 scroll 材质路径。 */
function isRelevantScrollTarget(target: EventTarget | null) {
if (!(target instanceof Element) || target === document.documentElement || target === document.body) return true
return observedSurfaces.some(surface => target.contains(surface))
}
function handleScrollIntent(event: Event) {
if (event instanceof WheelEvent && event.deltaX === 0 && event.deltaY === 0) return
if (event instanceof KeyboardEvent) {
const target = event.target
if (
target instanceof HTMLElement &&
(target.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName))
) {
return
}
if (!['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' '].includes(event.key)) return
}
const scrollTarget = resolveScrollIntentTarget(event)
if (!scrollTarget || !isRelevantScrollTarget(scrollTarget)) return
beginNativeScrollPresentation()
}
function updateRendererState(value: GlassRendererState) {
if (options.syncDocumentState === false) {
state.value = value
return
}
setGlassRendererState(state, value)
}
/** 只在 presentation 提交点读取 DOM 尺寸,避免渲染帧反复触发布局计算。 */
function measurePresentationSize() {
if (presentationSpace === 'fixed') {
return { height: window.innerHeight, width: window.innerWidth }
}
const root = options.canvas.value?.parentElement
return {
height: Math.max(window.innerHeight, root?.scrollHeight ?? 0, document.documentElement.scrollHeight),
width: Math.max(window.innerWidth, root?.scrollWidth ?? 0, document.documentElement.scrollWidth),
}
}
/** 帧内逻辑复用最近一次已提交尺寸ResizeObserver 与 motion 事务负责刷新该值。 */
function getCommittedPresentationSize() {
const committed = resources?.uniforms.uPresentationSize.value
return committed
? { height: committed.y, width: committed.x }
: { height: window.innerHeight, width: window.innerWidth }
}
function getPresentationPoint(clientX: number, clientY: number) {
if (presentationSpace === 'fixed') return { x: clientX, y: clientY }
return { x: clientX + window.scrollX, y: clientY + window.scrollY }
}
/** 将当前 DOM 边界转换为 renderer 使用的 fixed 或 scroll presentation 坐标。 */
function getElementPresentationRect(element: HTMLElement): GlassOpticalRect {
const bounds = element.getBoundingClientRect()
const coordinateOffsetX = presentationSpace === 'scroll' ? window.scrollX : 0
const coordinateOffsetY = presentationSpace === 'scroll' ? window.scrollY : 0
return {
height: bounds.height,
radii: [...readBorderRadii(element)] as GlassCornerRadii,
rank: 0,
width: bounds.width,
x: bounds.left + coordinateOffsetX,
y: bounds.top + coordinateOffsetY,
}
}
/** scroll 呈现层必须跟随页面异步撑高,即使页面内没有可发现的光学表面。 */
function observeResizeTargets(reset = true) {
if (reset) resizeObserver?.disconnect()
if (presentationSpace === 'scroll') {
const presentationRoot = options.canvas.value?.parentElement
if (presentationRoot) resizeObserver?.observe(presentationRoot)
}
for (const element of observedSurfaces) resizeObserver?.observe(element)
}
function cancelScheduledFrame() {
if (animationFrame === null) return
cancelAnimationFrame(animationFrame)
animationFrame = null
}
function cancelScrollFrame() {
if (scrollAnimationFrame !== null) cancelAnimationFrame(scrollAnimationFrame)
scrollAnimationFrame = null
scrollDirty = false
scrollFrameCommitted = false
scrollGeometryRefreshPending = false
scrollLateGeometryCommitted = false
scrollSurfaceStabilityPending = false
scrollStableFrameCount = 0
clearScrollPresentationRestoreTimer()
}
function cancelWallpaperTransitionFrame() {
if (wallpaperTransitionFrame === null) return
cancelAnimationFrame(wallpaperTransitionFrame)
wallpaperTransitionFrame = null
}
function cancelSurfaceTransformFrame() {
if (surfaceTransformFrame !== null) cancelAnimationFrame(surfaceTransformFrame)
surfaceTransformFrame = null
surfaceTransformTrackingDeadline = 0
transformingSurfaces.clear()
}
function clearBackgroundDisposeTimer() {
if (backgroundDisposeTimer === null) return
window.clearTimeout(backgroundDisposeTimer)
backgroundDisposeTimer = null
}
/** 源纹理与磨砂预滤目标共享同一壁纸生命周期,必须成对释放。 */
function disposeWallpaperResources(texture: Texture | null, frostedTarget: WebGLRenderTarget | null) {
texture?.dispose()
frostedTarget?.dispose()
}
/** 释放已完成过渡的旧纹理,并让两个采样槽继续指向同一稳定壁纸。 */
function finishWallpaperTransition() {
if (!resources || !activeTexture) return
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = null
previousFrostedTarget = null
previousTextureHeight = activeTextureHeight
previousTextureWidth = activeTextureWidth
previousWallpaperExposure = activeWallpaperExposure
resources.uniforms.uPreviousTexture.value = activeTexture
resources.uniforms.uPreviousFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uPreviousCoverScale.value.copy(resources.uniforms.uCoverScale.value)
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
resources.uniforms.uTextureMix.value = 1
wallpaperTransitionStartedAt = 0
wallpaperActivationRollback = null
cancelWallpaperTransitionFrame()
}
/** 使用外层壁纸的 CSS ease 时钟推进双纹理,不创建常驻动画帧。 */
function updateWallpaperTransition(timestamp: number) {
if (!resources || !previousTexture) return false
const duration = toValue(options.transitionDuration ?? 0)
const progress = getGlassWallpaperTransitionProgress(timestamp - wallpaperTransitionStartedAt, duration)
resources.uniforms.uTextureMix.value = progress
if (progress >= 1) {
finishWallpaperTransition()
return false
}
return true
}
function renderWallpaperTransitionFrame(timestamp: number) {
wallpaperTransitionFrame = null
if (document.visibilityState === 'hidden') return
renderFrame(timestamp)
if (previousTexture && wallpaperTransitionFrame === null) {
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
}
}
function scheduleWallpaperTransition() {
if (wallpaperTransitionFrame !== null || !previousTexture || document.visibilityState === 'hidden') return
wallpaperTransitionFrame = requestAnimationFrame(renderWallpaperTransitionFrame)
}
/** 释放壁纸准备阶段复用的预滤 shader活动低通纹理由各自 RenderTarget 单独持有。 */
function disposeFrostPrefilterResources() {
if (!frostPrefilterResources) return
frostPrefilterResources.material.dispose()
frostPrefilterResources = null
}
/** 为当前 WebGL context 创建一次性低分辨率壁纸预滤管线。 */
function getFrostPrefilterResources() {
if (frostPrefilterResources) return frostPrefilterResources
if (!resources || !three) return null
const uniforms = {
uDirection: { value: new three.Vector2(1, 0) },
uTexture: { value: null },
uTextureSize: { value: new three.Vector2(1, 1) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: FROST_PREFILTER_FRAGMENT_SHADER,
uniforms,
vertexShader: VERTEX_SHADER,
})
const scene = new three.Scene()
const mesh = new three.Mesh(resources.geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
frostPrefilterResources = { material, mesh, scene, uniforms }
return frostPrefilterResources
}
/** 壁纸上传时执行两次 separable blur常态只保留既定分辨率的低通 RenderTarget。 */
async function createFrostedWallpaperTarget(texture: Texture, width: number, height: number, targetLongEdge: number) {
if (!resources || !three) return null
const ownerResources = resources
const prefilter = getFrostPrefilterResources()
if (!prefilter) return null
const targetScale = Math.min(1, targetLongEdge / Math.max(width, height))
const targetWidth = Math.max(1, Math.round(width * targetScale))
const targetHeight = Math.max(1, Math.round(height * targetScale))
const createTarget = (generateMipmaps = false) =>
new three!.WebGLRenderTarget(targetWidth, targetHeight, {
depthBuffer: false,
generateMipmaps,
magFilter: three!.LinearFilter,
minFilter: generateMipmaps ? three!.LinearMipmapLinearFilter : three!.LinearFilter,
stencilBuffer: false,
})
const intermediateTarget = createTarget()
const outputTarget = createTarget(true)
const previousTarget = ownerResources.renderer.getRenderTarget()
try {
ownerResources.renderer.initTexture(texture)
await ownerResources.renderer.compileAsync(prefilter.scene, ownerResources.camera)
if (resources !== ownerResources) {
outputTarget.dispose()
return null
}
prefilter.uniforms.uDirection.value.set(1, 0)
prefilter.uniforms.uTexture.value = texture
prefilter.uniforms.uTextureSize.value.set(width, height)
ownerResources.renderer.setRenderTarget(intermediateTarget)
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
prefilter.uniforms.uDirection.value.set(0, 1)
prefilter.uniforms.uTexture.value = intermediateTarget.texture
// 两轴都按原图像素定义核半径;低分辨率 target 只负责存储,不放大纵向采样步长。
prefilter.uniforms.uTextureSize.value.set(width, height)
ownerResources.renderer.setRenderTarget(outputTarget)
ownerResources.renderer.render(prefilter.scene, ownerResources.camera)
return outputTarget
} catch (error) {
outputTarget.dispose()
throw error
} finally {
if (resources === ownerResources) ownerResources.renderer.setRenderTarget(previousTarget)
intermediateTarget.dispose()
}
}
/** 释放仅由高质量档使用的短时液态位移场。 */
function disposeFlowResources() {
if (!flowResources) return
flowResources.material.dispose()
flowResources.readTarget.dispose()
flowResources.writeTarget.dispose()
flowResources = null
if (resources) {
resources.uniforms.uFlowTexture.value = null
resources.uniforms.uHasFlowTexture.value = 0
}
}
/** 根据当前质量档创建或释放共享 renderer 内的液态位移场。 */
function syncFlowResources() {
if (!resources || !three) return
const profile = getRenderProfile()
if (!profile.flowField) {
disposeFlowResources()
return
}
if (flowResources) return
const createTarget = () =>
new three!.WebGLRenderTarget(1, 1, {
depthBuffer: false,
magFilter: three!.LinearFilter,
minFilter: three!.LinearFilter,
stencilBuffer: false,
})
const uniforms: GlassFlowUniforms = {
uDecay: { value: 1 },
uInjection: { value: 0 },
uPointer: { value: resources.uniforms.uPointer.value },
uPrevious: { value: null },
uTexelSize: { value: new three.Vector2(1, 1) },
uVelocity: { value: resources.uniforms.uPointerVelocity.value },
uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: FLOW_FRAGMENT_SHADER,
uniforms,
vertexShader: VERTEX_SHADER,
})
const scene = new three.Scene()
const mesh = new three.Mesh(resources.geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
flowResources = {
material,
mesh,
readTarget: createTarget(),
scene,
uniforms,
writeTarget: createTarget(),
}
resources.uniforms.uHasFlowTexture.value = 1
}
function renderFrame(timestamp = performance.now(), advanceFlow = true) {
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
updateWallpaperTransition(timestamp)
if (flowResources && advanceFlow) {
resources.renderer.setScissorTest(false)
flowResources.uniforms.uPrevious.value = flowResources.readTarget.texture
resources.renderer.setRenderTarget(flowResources.writeTarget)
resources.renderer.render(flowResources.scene, resources.camera)
resources.renderer.setRenderTarget(null)
const previousReadTarget = flowResources.readTarget
flowResources.readTarget = flowResources.writeTarget
flowResources.writeTarget = previousReadTarget
resources.uniforms.uFlowTexture.value = flowResources.readTarget.texture
}
if (presentationSpace === 'scroll') {
const { height: presentationHeight } = getCommittedPresentationSize()
const scaleY = presentationBufferHeight / Math.max(presentationHeight, 1)
const scissorY = Math.max(0, Math.floor((presentationHeight - window.scrollY - window.innerHeight) * scaleY))
const scissorHeight = Math.min(presentationBufferHeight - scissorY, Math.ceil(window.innerHeight * scaleY) + 2)
resources.renderer.setScissorTest(true)
resources.renderer.setScissor(0, scissorY, presentationBufferWidth, scissorHeight)
resources.renderer.clear()
} else {
resources.renderer.setScissorTest(false)
}
resources.renderer.render(resources.scene, resources.camera)
renderedFrames.value += 1
}
function renderScheduledFrame(timestamp: number) {
animationFrame = null
renderFrame(timestamp)
}
function scheduleFrame() {
if (animationFrame !== null || !resources) return
animationFrame = requestAnimationFrame(renderScheduledFrame)
}
/** 将稳定槽位及其短时交叉权重写入固定长度的 shader 协议。 */
function writeSurfaceUniforms(timestamp = performance.now()) {
if (!resources) return
if (outgoingSurface && timestamp - surfaceTransitionStartedAt >= SURFACE_TRANSITION_DURATION_MS) {
outgoingSurface = null
surfaceSlots = surfaceSlots
.filter(slot => slot.role !== 'outgoing')
.map(slot => ({
...slot,
role: slot.key === activeSurface ? ('active' as const) : ('stable' as const),
}))
}
const presentation = getCommittedPresentationSize()
const normalizedInteractionClips = interactionClips.map(clip =>
normalizeGlassOpticalRect(clip.rect, presentation.width, presentation.height),
)
const uniformInteractionRects = resources.uniforms.uInteractionRects.value
const uniformInteractionRadii = resources.uniforms.uInteractionRadii.value
for (let index = 0; index < 8; index += 1) {
const clip = normalizedInteractionClips[index]
const rect = clip?.rect ?? [0, 0, 0, 0]
const radii = clip?.radii ?? [0, 0, 0, 0]
uniformInteractionRects[index].set(rect[0], rect[1], rect[2], rect[3])
uniformInteractionRadii[index].set(radii[0], radii[1], radii[2], radii[3])
}
resources.uniforms.uInteractionRectCount.value = normalizedInteractionClips.length
const normalized = surfaceSlots.map(slot =>
normalizeGlassOpticalRect(slot.rect, presentation.width, presentation.height),
)
const uniformRects = resources.uniforms.uRects.value
const uniformRadii = resources.uniforms.uRadii.value
const uniformWeights = resources.uniforms.uSurfaceWeights.value
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
const transitionWeights = outgoingSurface
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
: { incoming: 1, outgoing: 0 }
const pageMotionOpacity =
presentationSpace === 'scroll' ? Math.min(1, Math.max(0, toValue(options.pageMotion?.opacity ?? 1))) : 1
const pagePresentationWeight = pagePresentationGeometryReady ? pageMotionOpacity : 0
for (let index = 0; index < 8; index += 1) {
const surface = normalized[index]
const slot = surfaceSlots[index]
const rect = surface?.rect ?? [0, 0, 0, 0]
const radii = surface?.radii ?? [0, 0, 0, 0]
uniformRects[index].set(rect[0], rect[1], rect[2], rect[3])
uniformRadii[index].set(radii[0], radii[1], radii[2], radii[3])
const surfaceWeight =
slot?.role === 'outgoing'
? transitionWeights.outgoing
: slot?.role === 'active'
? transitionWeights.incoming
: slot
? 1
: 0
uniformWeights[index] = surfaceWeight * pagePresentationWeight
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
}
resources.uniforms.uRectCount.value = normalized.length
}
/**
* 材质父表面可折叠多个交互卡片;共享动态场只在最终输出时按真实卡片边界裁剪。
* 活动卡优先占用固定预算,其余可见卡片继续消费同一时序场。
*/
function refreshInteractionClipRegistry() {
const seen = new Set<HTMLElement>()
const candidates: GlassInteractionClipDescriptor[] = []
const append = (
element: HTMLElement,
owner: HTMLElement,
mode: GlassOpticalSurfaceMode,
committedRect?: GlassOpticalRect,
) => {
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
const rect = committedRect ?? getElementPresentationRect(element)
seen.add(element)
candidates.push({ key: element, mode, owner, rect })
}
for (const surface of surfaceRegistry) {
const mode = surface.mode ?? 'dynamic'
if (mode === 'static-material') continue
const surfaceIsClip = surface.key.matches(INTERACTION_CLIP_SELECTOR)
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
if (nestedClips.length > 0) {
nestedClips.forEach(clip => append(clip, surface.key, mode))
} else if (!surfaceIsClip) {
append(surface.key, surface.key, mode, surface.rect)
}
}
interactionClipRegistry = candidates
interactionClipMembershipDirty = false
}
/** 稳定帧复用成员集合;顶层 clip 直接复用 surface collector 已提交的矩形。 */
function refreshInteractionClipGeometry() {
const committedSurfaces = new Map(surfaceRegistry.map(surface => [surface.key, surface]))
interactionClipRegistry = interactionClipRegistry.flatMap(clip => {
if (
!clip.key.isConnected ||
!clip.owner.isConnected ||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
!committedSurfaces.has(clip.owner)
) {
return []
}
const rect = committedSurfaces.get(clip.key)?.rect ?? getElementPresentationRect(clip.key)
return [{ ...clip, rect }]
})
}
function isInteractionClipRenderable(rect: GlassOpticalRect) {
return rect.width >= 24 && rect.height >= 24
}
function updateInteractionClips() {
const slotKeys = new Set(surfaceSlots.map(slot => slot.key))
const viewportX = presentationSpace === 'scroll' ? window.scrollX : 0
const viewportY = presentationSpace === 'scroll' ? window.scrollY : 0
const interactionPoint = getPresentationPoint(lastPointerX, lastPointerY)
const candidates = interactionClipRegistry
.filter(candidate => slotKeys.has(candidate.owner))
.map(candidate => {
const { rect } = candidate
const visibleWidth = Math.min(viewportX + window.innerWidth, rect.x + rect.width) - Math.max(viewportX, rect.x)
const visibleHeight =
Math.min(viewportY + window.innerHeight, rect.y + rect.height) - Math.max(viewportY, rect.y)
const inOverscan =
rect.x + rect.width >= viewportX - INTERACTION_CLIP_OVERSCAN_PX &&
rect.x <= viewportX + window.innerWidth + INTERACTION_CLIP_OVERSCAN_PX &&
rect.y + rect.height >= viewportY - INTERACTION_CLIP_OVERSCAN_PX &&
rect.y <= viewportY + window.innerHeight + INTERACTION_CLIP_OVERSCAN_PX
const distance = Math.hypot(
rect.x + rect.width * 0.5 - interactionPoint.x,
rect.y + rect.height * 0.5 - interactionPoint.y,
)
return {
candidate,
distance,
visible: visibleWidth >= 24 && visibleHeight >= 24,
inOverscan,
}
})
.filter(
entry =>
isInteractionClipRenderable(entry.candidate.rect) &&
(entry.candidate.key === activeInteractionClip || entry.inOverscan),
)
.sort((left, right) => {
const leftActive = left.candidate.key === activeInteractionClip
const rightActive = right.candidate.key === activeInteractionClip
if (leftActive !== rightActive) return leftActive ? -1 : 1
if (left.visible !== right.visible) return left.visible ? -1 : 1
return left.distance - right.distance
})
.map(entry => entry.candidate)
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
interactionClips = candidates.slice(0, maxCount)
}
/** 从缓存的 document-space 几何选择当前可见表面并提交 shader slot。 */
function updateVisibleSurfaceUniforms(timestamp: number) {
const viewportWidth = window.innerWidth
availableSurfaces = selectVisibleGlassOpticalSurfaceDescriptors(
surfaceRegistry,
viewportWidth,
window.innerHeight,
presentationSpace,
)
const availableKeys = new Set(availableSurfaces.map(surface => surface.key))
if (activeSurface && !availableKeys.has(activeSurface)) {
activeSurface = null
activeInteractionClip = null
}
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
surfaceSlots,
availableSurfaces,
maxCount,
activeSurface ?? undefined,
outgoingSurface ?? undefined,
)
updateInteractionClips()
writeSurfaceUniforms(timestamp)
}
function updateSurfaceUniforms(timestamp = performance.now(), scheduleRender = true) {
if (!resources) return
const viewportWidth = window.innerWidth
const nextObservedSurfaces: HTMLElement[] = []
surfaceRegistry = collectGlassOpticalSurfaceDescriptors(
viewportWidth,
window.innerHeight,
toValue(options.appearance),
presentationSpace,
true,
nextObservedSurfaces,
)
if (interactionClipMembershipDirty) refreshInteractionClipRegistry()
else refreshInteractionClipGeometry()
updateVisibleSurfaceUniforms(timestamp)
const observedSurfacesChanged =
nextObservedSurfaces.length !== observedSurfaces.length ||
nextObservedSurfaces.some((element, index) => element !== observedSurfaces[index])
if (observedSurfacesChanged) {
observedSurfaces = nextObservedSurfaces
observeResizeTargets()
}
if (scheduleRender) scheduleFrame()
}
/**
* 滚动事务内的几何失效共用一个稳定尾帧。
* 虚拟列表若在当前滚动帧提交后才挂载节点,必须在本次绘制前同步新蒙版。
*/
function queueScrollGeometryRefresh(stabilize: boolean) {
if (presentationSpace !== 'scroll' || scrollAnimationFrame === null || !resources) return false
scrollSurfaceStabilityPending ||= stabilize
scrollStableFrameCount = 0
if (!scrollFrameCommitted) {
scrollGeometryRefreshPending = true
return true
}
if (scrollLateGeometryCommitted) return true
scrollLateGeometryCommitted = true
scrollGeometryRefreshPending = false
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
return true
}
function scheduleSurfaceUpdate() {
if (queueScrollGeometryRefresh(false)) return
if (surfaceUpdateFrame !== null || !resources) return
surfaceUpdateFrame = requestAnimationFrame(timestamp => {
surfaceUpdateFrame = null
updateSurfaceUniforms(timestamp, false)
// 表面失效必须在同一有界帧内清除旧像素,不能等待下一次指针或壁纸事件。
renderFrame(timestamp, false)
})
}
/** DOM 重排后连续采样少量帧,避免把虚拟列表的中间几何误认为最终表面。 */
function scheduleSurfaceStabilityUpdate() {
if (queueScrollGeometryRefresh(true)) return
surfaceStabilityPass = 0
surfaceStableFrameCount = 0
lastSurfaceGeometrySignature = ''
if (surfaceStabilityFrame !== null || !resources) return
const sample = (timestamp: number) => {
surfaceStabilityFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') return
updateSurfaceUniforms(timestamp, false)
const signature = surfaceSlots
.map(slot => {
const { height, width, x, y } = slot.rect
return `${x.toFixed(2)},${y.toFixed(2)},${width.toFixed(2)},${height.toFixed(2)}`
})
.join('|')
surfaceStableFrameCount = signature === lastSurfaceGeometrySignature ? surfaceStableFrameCount + 1 : 0
lastSurfaceGeometrySignature = signature
surfaceStabilityPass += 1
// DOM 删除和虚拟列表重排只刷新合成几何,不推进已有液态流场。
renderFrame(timestamp, false)
if (
surfaceStableFrameCount < SURFACE_STABILITY_REQUIRED_FRAMES &&
surfaceStabilityPass < SURFACE_STABILITY_MAX_FRAMES
) {
surfaceStabilityFrame = requestAnimationFrame(sample)
} else if (!pagePresentationGeometryReady) {
pagePresentationGeometryReady = true
writeSurfaceUniforms(timestamp)
renderFrame(timestamp, false)
}
}
surfaceStabilityFrame = requestAnimationFrame(sample)
}
/**
* 页面根的 scrollHeight 可能在 content box 稳定后继续收敛。
* 连续两个 80ms 样本一致才允许覆盖已提交的 presentation 首帧。
*/
function schedulePresentationResizeUpdate() {
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
const sample = () => {
presentationResizeTimer = null
if (!resources) return
const presentation = measurePresentationSize()
const candidate = `${window.innerWidth},${window.innerHeight},${presentation.width},${presentation.height}`
presentationResizeStableSamples =
candidate === presentationResizeCandidate ? presentationResizeStableSamples + 1 : 1
presentationResizeCandidate = candidate
if (presentationResizeStableSamples >= PRESENTATION_RESIZE_REQUIRED_SAMPLES) {
resizeRenderer()
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
return
}
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
}
presentationResizeTimer = window.setTimeout(sample, PRESENTATION_RESIZE_SAMPLE_MS)
}
/** 共享页面 motion 活跃时,页面几何变化必须在浏览器绘制前完成一次完整 presentation 提交。 */
function commitActivePagePresentation(timestamp = performance.now()) {
if (!resources || presentationSpace !== 'scroll' || !toValue(options.pageMotion?.active ?? false)) return false
if (presentationResizeTimer !== null) window.clearTimeout(presentationResizeTimer)
presentationResizeTimer = null
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
resizeRenderer()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
return true
}
/** 普通表面尺寸即时更新;页面根尺寸在稳定后覆盖 presentation。 */
function handleSurfaceResize(entries: ResizeObserverEntry[]) {
if (!resources) return
const presentationRoot = presentationSpace === 'scroll' ? options.canvas.value?.parentElement : null
const presentationChanged = presentationRoot && entries.some(entry => entry.target === presentationRoot)
if (presentationChanged && commitActivePagePresentation()) return
if (presentationChanged) schedulePresentationResizeUpdate()
if (!entries.some(entry => entry.target !== presentationRoot)) return
if (queueScrollGeometryRefresh(false)) return
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
}
/** CSS transform 不改变布局尺寸,过渡期间用有界帧同步真实几何并清除旧蒙版。 */
function scheduleSurfaceTransformFrame() {
if (queueScrollGeometryRefresh(false)) return
if (surfaceTransformFrame !== null || !resources) return
surfaceTransformFrame = requestAnimationFrame(timestamp => {
surfaceTransformFrame = null
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
cancelSurfaceTransformFrame()
return
}
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
if (transformingSurfaces.size > 0 && timestamp < surfaceTransformTrackingDeadline) {
scheduleSurfaceTransformFrame()
} else {
transformingSurfaces.clear()
scheduleSurfaceStabilityUpdate()
}
})
}
function resolveTransitionSurface(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
const surface = target.matches(SURFACE_SELECTOR_QUERY)
? target
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
if (!surface) return null
return SURFACE_SELECTORS.some(
({ selector, space }) =>
surface.matches(selector) && getSurfacePresentationSpace(selector, space) === presentationSpace,
)
? surface
: null
}
function handleSurfaceTransitionRun(event: TransitionEvent) {
if (event.propertyName !== 'transform') return
const surface = resolveTransitionSurface(event.target)
if (!surface) return
transformingSurfaces.add(surface)
surfaceTransformTrackingDeadline = performance.now() + SURFACE_TRANSFORM_TRACKING_MAX_MS
scheduleSurfaceTransformFrame()
}
function handleSurfaceTransitionEnd(event: TransitionEvent) {
if (event.propertyName !== 'transform') return
const surface = resolveTransitionSurface(event.target)
if (!surface) return
transformingSurfaces.delete(surface)
if (transformingSurfaces.size === 0) scheduleSurfaceStabilityUpdate()
}
function syncCoverScale(viewportWidth = window.innerWidth, viewportHeight = window.innerHeight) {
if (!resources) return
const cover = getGlassCoverScale(viewportWidth, viewportHeight, activeTextureWidth, activeTextureHeight)
const previousCover = getGlassCoverScale(viewportWidth, viewportHeight, previousTextureWidth, previousTextureHeight)
resources.uniforms.uCoverScale.value.set(cover.x, cover.y)
resources.uniforms.uPreviousCoverScale.value.set(previousCover.x, previousCover.y)
}
function getRenderProfile(routeKey = toValue(options.routeKey)) {
return getGlassOpticalRenderProfile(toValue(options.quality), routeKey)
}
function getWallpaperPreparationKey(url: string) {
return getGlassWallpaperPreparationKey(
toValue(options.appearance),
toValue(options.quality),
toValue(options.routeKey),
url,
)
}
/** 清理当前 context 的 prepared bundle但不改变活动或上一张纹理。 */
function releasePreparedWallpaper() {
if (preparedWallpaper) {
disposeWallpaperResources(preparedWallpaper.texture, preparedWallpaper.frostedTarget)
}
preparedWallpaper = null
preparedWallpaperUrl.value = ''
preparedWallpaperRevision.value = 0
preparedWallpaperPreparationKey.value = ''
}
/** 清除上一笔准备失败身份,避免旧错误取消后续 revision。 */
function clearPreparedWallpaperFailure() {
failedWallpaperUrl.value = ''
failedWallpaperRevision.value = 0
failedWallpaperPreparationKey.value = ''
}
/** 使所有进行中的准备任务失效,迟到结果只能释放自身资源。 */
function invalidatePreparedWallpaper() {
prepareVersion += 1
releasePreparedWallpaper()
clearPreparedWallpaperFailure()
}
function preparePendingWallpaper() {
const url = toValue(options.pendingWallpaperUrl ?? '')
const revision = toValue(options.pendingWallpaperRevision ?? 0)
if (url && revision > 0) void prepareWallpaper(url, revision)
}
function getLegacyDynamicStrength() {
return toValue(options.motionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT)
}
function getTranslationStrengthScale() {
return getGlassOpticalTranslationStrengthScale(toValue(options.translationStrength ?? getLegacyDynamicStrength()))
}
function getDeformationStrengthScale() {
return getGlassOpticalDeformationStrengthScale(toValue(options.deformationStrength ?? getLegacyDynamicStrength()))
}
function getFlowStrengthScale() {
return getGlassOpticalFlowStrengthScale(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
}
function getMotionExpansion() {
return getGlassOpticalMotionExpansion(toValue(options.flowStrength ?? getLegacyDynamicStrength()))
}
function getMaxRefractionPixels() {
return getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
)
}
function getReflectionStrengthScale() {
return getGlassOpticalReflectionStrengthScale(toValue(options.reflectionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT))
}
function getMaterialResponse() {
return getGlassMaterialResponse(
toValue(options.appearance),
toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
)
}
function resizeRenderer() {
if (!resources) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const presentation = measurePresentationSize()
const profile = getRenderProfile()
const buffer =
presentationSpace === 'scroll'
? getGlassScrollBufferSize(
presentation.width,
presentation.height,
profile.bufferQuality,
window.devicePixelRatio,
)
: getGlassOpticalBufferSize(
viewportWidth,
viewportHeight,
viewportWidth <= 600,
profile.bufferQuality,
window.devicePixelRatio,
)
const bufferChanged = presentationBufferWidth !== buffer.width || presentationBufferHeight !== buffer.height
const presentationChanged =
resources.uniforms.uPresentationSize.value.x !== presentation.width ||
resources.uniforms.uPresentationSize.value.y !== presentation.height
presentationBufferHeight = buffer.height
presentationBufferWidth = buffer.width
if (bufferChanged) resources.renderer.setSize(buffer.width, buffer.height, false)
resources.uniforms.uVisibleViewportSize.value.set(viewportWidth, viewportHeight)
resources.uniforms.uPresentationSize.value.set(presentation.width, presentation.height)
resources.uniforms.uScrollOffset.value.set(
presentationSpace === 'scroll' ? window.scrollX : 0,
presentationSpace === 'scroll' ? window.scrollY : 0,
)
if (flowResources) {
const flowWidth = Math.max(96, Math.round(buffer.width * FLOW_BUFFER_SCALE))
const flowHeight = Math.max(96, Math.round(buffer.height * FLOW_BUFFER_SCALE))
const flowBufferChanged =
flowResources.readTarget.width !== flowWidth || flowResources.readTarget.height !== flowHeight
if (flowBufferChanged) {
flowResources.readTarget.setSize(flowWidth, flowHeight)
flowResources.writeTarget.setSize(flowWidth, flowHeight)
}
flowResources.uniforms.uTexelSize.value.set(1 / flowWidth, 1 / flowHeight)
flowResources.uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
}
syncCoverScale(viewportWidth, viewportHeight)
updateSurfaceUniforms(performance.now(), false)
// buffer 重分配或归一化呈现尺寸变化都必须在当前绘制周期提交稳定画面。
if (bufferChanged || presentationChanged) renderFrame(performance.now(), false)
else scheduleSurfaceUpdate()
}
function profileRequiresTextureReload(
previousProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
nextProfile: ReturnType<typeof getGlassOpticalRenderProfile>,
) {
const wallpaperUrl = toValue(options.wallpaperUrl)
const resolveTextureSource = (profile: ReturnType<typeof getGlassOpticalRenderProfile>) =>
profile.textureSource === 'auto'
? canUseGlassWallpaperTexture(wallpaperUrl, window.location.href)
? 'wallpaper'
: 'procedural'
: profile.textureSource
return (
previousProfile.textureLimit !== nextProfile.textureLimit ||
resolveTextureSource(previousProfile) !== resolveTextureSource(nextProfile)
)
}
function rectContainsPoint(rect: GlassOpticalRect, x: number, y: number) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
}
/** 折叠的交互卡片不占用材质槽,但动态反馈仍应限制在其真实圆角边界内。 */
function findInteractionTarget(x: number, y: number) {
if (availableSurfaces.length === 0) updateSurfaceUniforms()
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
if (!surface) return null
const matchingClips = interactionClipRegistry
.filter(
candidate =>
candidate.owner === surface.key &&
isInteractionClipRenderable(candidate.rect) &&
rectContainsPoint(candidate.rect, x, y),
)
.sort((left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height)
return {
clip: matchingClips[0]?.key ?? surface.key,
surface,
}
}
/**
* 跨表面移动只交接活动身份,不重排其余槽位或让轨迹穿过卡片间隙。
* 已在预算内的表面保持完整权重;只有预算外表面进入时才让被替换表面单调淡出。
*/
function activateInteractionSurface(surface: HTMLElement, timestamp: number, reducedMotion: boolean) {
if (activeSurface === surface) {
return false
}
const surfaceAlreadyHasSlot = surfaceSlots.some(slot => slot.key === surface)
outgoingSurface = reducedMotion || surfaceAlreadyHasSlot ? null : activeSurface
activeSurface = surface
surfaceTransitionStartedAt = timestamp
const maxCount = window.innerWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
surfaceSlots,
availableSurfaces,
maxCount,
activeSurface,
outgoingSurface ?? undefined,
)
updateInteractionClips()
writeSurfaceUniforms(timestamp)
return true
}
/** 指针位置只在真实输入事件内推进,停止后由能量衰减负责收敛。 */
function updatePointerTarget(x: number, y: number, response: number) {
if (!resources) return
pointerTargetX = x
pointerTargetY = y
const clampedResponse = Math.min(1, Math.max(0, response))
pointerPositionX += (pointerTargetX - pointerPositionX) * clampedResponse
pointerPositionY += (pointerTargetY - pointerPositionY) * clampedResponse
resources.uniforms.uPointer.value.set(pointerPositionX, pointerPositionY)
}
function snapPointer(x: number, y: number) {
pointerTargetX = x
pointerTargetY = y
pointerPositionX = x
pointerPositionY = y
resources?.uniforms.uPointer.value.set(x, y)
}
/** 快速输入在单次事件内补齐视口路径,避免事件稀疏时只留下孤立触点。 */
function updateTrail(
x: number,
y: number,
timestamp: number,
previousNormalizedX: number,
previousNormalizedY: number,
) {
if (!resources) return
const presentation = getCommittedPresentationSize()
const normalizedX = x / Math.max(presentation.width, 1)
const normalizedY = 1 - y / Math.max(presentation.height, 1)
const distance = Math.hypot(normalizedX - previousNormalizedX, normalizedY - previousNormalizedY)
if (timestamp - lastTrailAt < 36 && distance < 0.012) return
const trail = resources.uniforms.uTrail.value
const interpolatedSamples = Math.min(getRenderProfile().trailCount, Math.max(1, Math.ceil(distance / 0.045)))
if (interpolatedSamples > 1) {
for (let index = 0; index < trail.length; index += 1) {
if (index >= interpolatedSamples) {
trail[index].z = 0
continue
}
const progress = index / (interpolatedSamples - 1)
trail[index].set(
normalizedX + (previousNormalizedX - normalizedX) * progress,
normalizedY + (previousNormalizedY - normalizedY) * progress,
1 - progress * 0.28,
0,
)
}
lastTrailAt = timestamp
return
}
for (let index = trail.length - 1; index > 0; index -= 1) {
trail[index].copy(trail[index - 1])
trail[index].z *= 0.74
}
trail[0].set(normalizedX, normalizedY, 1, 0)
lastTrailAt = timestamp
}
/** 清除短时交互能量,确保静止帧不保留时序流场或输入方向。 */
function resetInteractionState() {
pendingFlowInjection = 0
lastInteractionFrameAt = 0
lastTrailAt = Number.NEGATIVE_INFINITY
activeInteractionClip = null
interactionClips = []
if (!resources) return
snapPointer(pointerTargetX, pointerTargetY)
resources.uniforms.uInteractionRectCount.value = 0
resources.uniforms.uMotion.value = 0
resources.uniforms.uPointerVelocity.value.set(0, 0)
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
if (flowResources) {
flowResources.uniforms.uDecay.value = 0
flowResources.uniforms.uInjection.value = 0
}
}
function renderInteractionFrame(timestamp: number) {
if (!resources || !toValue(options.active) || document.visibilityState === 'hidden') {
animationFrame = null
interactionAnimating = false
return
}
const profile = getRenderProfile()
const elapsed = Math.max(0, timestamp - lastInteractionAt)
const flowScale = getFlowStrengthScale()
const durationScale = Math.max(0.35, 0.55 + flowScale * 0.45)
const motionDuration = profile.motionDuration * durationScale
const motionHalfLife = profile.motionHalfLife * durationScale
const motion = getGlassOpticalMotionEnergy(elapsed, motionDuration, motionHalfLife)
writeSurfaceUniforms(timestamp)
if (motion <= 0) {
resetInteractionState()
renderFrame()
interactionAnimating = false
return
}
const delta = lastInteractionFrameAt > 0 ? Math.min(64, Math.max(0, timestamp - lastInteractionFrameAt)) : 16.67
lastInteractionFrameAt = timestamp
// 收敛期只衰减能量;输入方向保留到最终清场,避免低能量归一化时发生方向跳变。
resources.uniforms.uMotion.value = motion
if (flowResources) {
flowResources.uniforms.uDecay.value = getGlassOpticalDecay(profile.flowHalfLife, delta)
flowResources.uniforms.uInjection.value = pendingFlowInjection
}
pendingFlowInjection = 0
renderFrame()
if (flowResources) flowResources.uniforms.uInjection.value = 0
animationFrame = requestAnimationFrame(renderInteractionFrame)
}
function startInteractionAnimation() {
if (interactionAnimating) return
cancelScheduledFrame()
interactionAnimating = true
lastInteractionFrameAt = 0
animationFrame = requestAnimationFrame(renderInteractionFrame)
}
/** 将鼠标、触摸或滚动输入转换为共享液态状态。 */
function applyInteraction(
clientX: number,
clientY: number,
timestamp: number,
velocityOverride?: { x: number; y: number },
) {
const viewportWidth = Math.max(window.innerWidth, 1)
const viewportHeight = Math.max(window.innerHeight, 1)
const presentation = getCommittedPresentationSize()
const point = getPresentationPoint(clientX, clientY)
const previousPoint = getPresentationPoint(lastPointerX, lastPointerY)
const elapsed = Math.max(8, timestamp - lastPointerAt)
const previousNormalizedX = previousPoint.x / Math.max(presentation.width, 1)
const previousNormalizedY = 1 - previousPoint.y / Math.max(presentation.height, 1)
const velocityX = velocityOverride?.x ?? ((clientX - lastPointerX) / viewportWidth) * Math.min(2, 16.67 / elapsed)
const velocityY = velocityOverride?.y ?? (-(clientY - lastPointerY) / viewportHeight) * Math.min(2, 16.67 / elapsed)
const velocityLength = Math.hypot(velocityX, velocityY)
const velocityScale = velocityLength > 0.09 ? 0.09 / velocityLength : 1
const interactionTarget = findInteractionTarget(point.x, point.y)
lastPointerX = clientX
lastPointerY = clientY
lastPointerAt = timestamp
if (!resources || !interactionTarget || interactionTarget.surface.mode === 'static-material') return
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
const interactionClipChanged = activeInteractionClip !== interactionTarget.clip
activeInteractionClip = interactionTarget.clip
const surfaceChanged = activateInteractionSurface(interactionTarget.surface.key, timestamp, reducedMotion)
if (interactionClipChanged && !surfaceChanged) {
updateInteractionClips()
writeSurfaceUniforms(timestamp)
}
const startsInteraction = !interactionAnimating
const hasTrailAnchor = Number.isFinite(lastTrailAt)
const normalizedX = point.x / Math.max(presentation.width, 1)
const normalizedY = 1 - point.y / Math.max(presentation.height, 1)
if (startsInteraction && !hasTrailAnchor) {
snapPointer(normalizedX, normalizedY)
updateTrail(point.x, point.y, timestamp, normalizedX, normalizedY)
} else {
updateTrail(point.x, point.y, timestamp, previousNormalizedX, previousNormalizedY)
const profile = getRenderProfile()
const directTouchResponse =
window.innerWidth <= 600 || matchMedia('(pointer: coarse)').matches || activeTouchIdentifier !== null
updatePointerTarget(normalizedX, normalizedY, directTouchResponse ? 1 : profile.pointerImmediateResponse)
}
const scaledVelocity = { x: velocityX * velocityScale, y: velocityY * velocityScale }
wakeDirection = getGlassOpticalWakeDirection(
wakeDirection,
scaledVelocity,
Math.hypot(scaledVelocity.x, scaledVelocity.y),
startsInteraction,
)
resources.uniforms.uPointerVelocity.value.set(scaledVelocity.x, scaledVelocity.y)
resources.uniforms.uWakeDirection.value.set(wakeDirection.x, wakeDirection.y)
resources.uniforms.uTrailCount.value = getRenderProfile().trailCount
lastInteractionAt = timestamp
if (reducedMotion) {
resetInteractionState()
scheduleFrame()
return
}
resources.uniforms.uMotion.value = 1
if (getFlowStrengthScale() <= 0) {
interactionAnimating = false
cancelScheduledFrame()
pendingFlowInjection = 0
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
if (flowResources) {
flowResources.uniforms.uDecay.value = 0
flowResources.uniforms.uInjection.value = 0
}
scheduleFrame()
return
}
pendingFlowInjection = Math.max(pendingFlowInjection, Math.min(1, velocityLength * 18))
startInteractionAnimation()
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now())
}
function findTouch(touches: TouchList, identifier: number | null) {
for (let index = 0; index < touches.length; index += 1) {
const touch = touches.item(index)
if (touch && (identifier === null || touch.identifier === identifier)) return touch
}
return null
}
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
function handleTouchStart(event: TouchEvent) {
if (activeTouchIdentifier !== null) return
const touch = findTouch(event.changedTouches, null)
if (!touch) return
const timestamp = event.timeStamp || performance.now()
activeTouchIdentifier = touch.identifier
lastPointerX = touch.clientX
lastPointerY = touch.clientY
lastPointerAt = timestamp
const point = getPresentationPoint(touch.clientX, touch.clientY)
const presentation = getCommittedPresentationSize()
const interactionTarget = findInteractionTarget(point.x, point.y)
if (resources && interactionTarget?.surface.mode === 'dynamic') {
activeInteractionClip = interactionTarget.clip
activateInteractionSurface(
interactionTarget.surface.key,
timestamp,
matchMedia('(prefers-reduced-motion: reduce)').matches,
)
snapPointer(point.x / Math.max(presentation.width, 1), 1 - point.y / Math.max(presentation.height, 1))
updateTrail(
point.x,
point.y,
timestamp,
point.x / Math.max(presentation.width, 1),
1 - point.y / Math.max(presentation.height, 1),
)
scheduleFrame()
}
}
function handleTouchMove(event: TouchEvent) {
const touch = findTouch(event.touches, activeTouchIdentifier)
if (!touch) return
const timestamp = event.timeStamp || performance.now()
applyInteraction(touch.clientX, touch.clientY, timestamp)
}
function handleTouchEnd(event: TouchEvent) {
if (!findTouch(event.changedTouches, activeTouchIdentifier)) return
activeTouchIdentifier = null
}
function handleInteractionEvent(event: PointerEvent | TouchEvent) {
if (event.type === 'pointermove') {
handlePointerMove(event as PointerEvent)
return
}
if (event.type === 'touchstart') {
handleTouchStart(event as TouchEvent)
return
}
if (event.type === 'touchmove') {
handleTouchMove(event as TouchEvent)
return
}
handleTouchEnd(event as TouchEvent)
}
/** 文档滚动只更新采样坐标和缓存可见性;嵌套滚动仍在首帧刷新受影响的表面几何。 */
function renderScrollFrame(timestamp: number) {
scrollAnimationFrame = null
scrollFrameCommitted = false
scrollLateGeometryCommitted = false
if (
presentationSpace !== 'scroll' ||
!resources ||
!toValue(options.active) ||
document.visibilityState === 'hidden'
) {
scrollDirty = false
scrollStableFrameCount = 0
return
}
const receivedScrollEvent = scrollDirty
scrollDirty = false
const scrollX = window.scrollX
const scrollY = window.scrollY
const coordinatesChanged = scrollX !== lastRenderedScrollX || scrollY !== lastRenderedScrollY
lastRenderedScrollX = scrollX
lastRenderedScrollY = scrollY
resources.uniforms.uScrollOffset.value.set(scrollX, scrollY)
if (scrollGeometryRefreshPending) {
scrollGeometryRefreshPending = false
updateSurfaceUniforms(timestamp, false)
} else if (receivedScrollEvent || coordinatesChanged) {
updateVisibleSurfaceUniforms(timestamp)
}
scrollStableFrameCount = receivedScrollEvent || coordinatesChanged ? 0 : scrollStableFrameCount + 1
if (!interactionAnimating) renderFrame(timestamp, false)
scrollFrameCommitted = true
if (transformingSurfaces.size > 0) {
if (timestamp < surfaceTransformTrackingDeadline) {
scrollGeometryRefreshPending = true
scrollStableFrameCount = 0
} else {
transformingSurfaces.clear()
scrollSurfaceStabilityPending = true
}
}
if (scrollStableFrameCount < SCROLL_STABLE_TAIL_FRAMES) {
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
} else {
finishNativeScrollPresentation(timestamp)
if (scrollSurfaceStabilityPending) {
scrollSurfaceStabilityPending = false
scheduleSurfaceStabilityUpdate()
}
}
}
function scheduleScrollFrame() {
if (scrollAnimationFrame !== null || presentationSpace !== 'scroll' || !resources) return
scrollAnimationFrame = requestAnimationFrame(renderScrollFrame)
}
function handleScroll(event: Event) {
if (presentationSpace !== 'scroll' || !resources) return
const target = event.target
if (!isRelevantScrollTarget(target)) return
beginNativeScrollPresentation()
scrollFrameCommitted = false
scrollLateGeometryCommitted = false
const isDocumentScroll =
!(target instanceof Element) || target === document.documentElement || target === document.body
if (!isDocumentScroll) {
if (!(target instanceof Element) || !observedSurfaces.some(surface => target.contains(surface))) return
scrollGeometryRefreshPending = true
} else {
resources.uniforms.uScrollOffset.value.set(window.scrollX, window.scrollY)
}
scrollDirty = true
scheduleScrollFrame()
if (surfaceUpdateFrame !== null) {
cancelAnimationFrame(surfaceUpdateFrame)
surfaceUpdateFrame = null
queueScrollGeometryRefresh(false)
}
if (surfaceStabilityFrame !== null) {
cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null
queueScrollGeometryRefresh(true)
}
if (surfaceTransformFrame !== null) {
cancelAnimationFrame(surfaceTransformFrame)
surfaceTransformFrame = null
queueScrollGeometryRefresh(false)
}
}
function handleScrollEnd(event: Event) {
if (presentationSpace !== 'scroll' || !resources) return
const target = event.target
if (
target instanceof Element &&
target !== document.documentElement &&
target !== document.body &&
observedSurfaces.some(surface => target.contains(surface))
) {
scrollGeometryRefreshPending = true
}
scrollDirty = false
scrollStableFrameCount = 0
scheduleScrollFrame()
}
/** 暂停事件驱动帧但保留 WebGL context、纹理、流场和最后一张稳定画面。 */
function pauseRenderer() {
cancelScheduledFrame()
cancelScrollFrame()
finishNativeScrollPresentation()
cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame()
interactionAnimating = false
}
/** 合并同一可见性事务的多个浏览器事件,只恢复一次稳定帧。 */
function resumeRenderer() {
if (resumePromise) return resumePromise
const version = resumeVersion
const canResume = () => toValue(options.active) && document.visibilityState !== 'hidden'
const task = (async () => {
clearBackgroundDisposeTimer()
if (!canResume()) return
await nextTick()
if (version !== resumeVersion || !canResume()) return
if (!resources) {
await initializeRenderer()
return
}
resizeRenderer()
updateSurfaceUniforms()
resetInteractionState()
renderFrame(performance.now())
scheduleWallpaperTransition()
})()
resumePromise = task
void task.finally(() => {
if (resumePromise === task) resumePromise = null
})
return task
}
/** 持续非活动才释放 GPU 资源,短时切换应用继续保留纹理与稳定画面。 */
function scheduleInactiveRendererDisposal(isStillInactive: () => boolean) {
clearBackgroundDisposeTimer()
backgroundDisposeTimer = window.setTimeout(() => {
backgroundDisposeTimer = null
if (!isStillInactive()) return
// 生命周期暂停后仍会复用同一 canvas只释放 renderer 资源,不主动丢失其 WebGL context。
disposeRenderer(false)
}, APP_ACTIVITY_SUSPEND_DELAY_MS)
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
pauseRenderer()
scheduleInactiveRendererDisposal(() => document.visibilityState === 'hidden')
return
}
void resumeRenderer()
}
function handleWindowBlur() {
if (document.visibilityState !== 'visible') return
pauseRenderer()
scheduleInactiveRendererDisposal(() => document.visibilityState === 'visible' && !document.hasFocus())
}
function handleWindowResume() {
if (document.visibilityState === 'visible') void resumeRenderer()
}
function handleContextLost(event: Event) {
event.preventDefault()
const canvas = options.canvas.value
disposeRenderer(false)
contextRecoveryPending = true
// 丢失的 context 已回收全部 GPU 对象;在恢复前释放旧 Three 状态,避免恢复后删除失效句柄。
canvas?.addEventListener('webglcontextrestored', handleContextRestored, { once: true })
updateRendererState('fallback')
}
function handleContextRestored() {
if (!contextRecoveryPending) return
contextRecoveryPending = false
void initializeRenderer(false)
}
function handleWindowResize() {
if (presentationSpace === 'scroll') schedulePresentationResizeUpdate()
else resizeRenderer()
}
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
return mutations.some(
mutation =>
mutation.type === 'attributes' ||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
)
}
function setupObservers() {
resizeObserver = new ResizeObserver(handleSurfaceResize)
observeResizeTargets(false)
const observedMutationRoots = new Set<Node>()
function observeMutationRoot(root: Node | null, subtree: boolean) {
if (!root || observedMutationRoots.has(root)) return
observedMutationRoots.add(root)
surfaceMutationObserver?.observe(root, {
attributeFilter: ['data-glass-optical-mode'],
attributes: true,
childList: true,
subtree,
})
}
surfaceMutationObserver = new MutationObserver(mutations => {
// Vuetify 可能在首个弹层打开时才创建容器,后续变更需要纳入同一个表面生命周期。
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
if (!mutationTouchesOpticalSurface(mutations)) return
interactionClipMembershipDirty = true
commitActivePagePresentation()
scheduleSurfaceStabilityUpdate()
})
observeMutationRoot(document.querySelector('.app-wrapper'), true)
observeMutationRoot(document.querySelector('.v-overlay-container'), true)
observeMutationRoot(document.body, false)
surfaceMutationObserver.observe(document.documentElement, {
attributeFilter: ['data-theme-radius'],
attributes: true,
})
}
function setupEvents() {
if (options.interactionSource) {
unsubscribeInteractionSource = options.interactionSource.subscribe(presentationSpace, handleInteractionEvent)
} else {
window.addEventListener('pointermove', handlePointerMove, { passive: true })
window.addEventListener('touchstart', handleTouchStart, { passive: true })
window.addEventListener('touchmove', handleTouchMove, { passive: true })
window.addEventListener('touchend', handleTouchEnd, { passive: true })
window.addEventListener('touchcancel', handleTouchEnd, { passive: true })
}
window.addEventListener('resize', handleWindowResize, { passive: true })
window.addEventListener('transitionrun', handleSurfaceTransitionRun, { capture: true, passive: true })
window.addEventListener('transitionend', handleSurfaceTransitionEnd, { capture: true, passive: true })
window.addEventListener('transitioncancel', handleSurfaceTransitionEnd, { capture: true, passive: true })
if (presentationSpace === 'scroll') {
window.addEventListener('wheel', handleScrollIntent, { capture: true, passive: true })
window.addEventListener('touchmove', handleScrollIntent, { capture: true, passive: true })
window.addEventListener('keydown', handleScrollIntent, { capture: true })
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
window.addEventListener('scrollend', handleScrollEnd, { passive: true })
}
options.canvas.value?.addEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.addEventListener('webglcontextrestored', handleContextRestored)
}
function removeEvents() {
unsubscribeInteractionSource?.()
unsubscribeInteractionSource = null
if (!options.interactionSource) {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('touchstart', handleTouchStart)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('touchend', handleTouchEnd)
window.removeEventListener('touchcancel', handleTouchEnd)
}
window.removeEventListener('resize', handleWindowResize)
window.removeEventListener('transitionrun', handleSurfaceTransitionRun, true)
window.removeEventListener('transitionend', handleSurfaceTransitionEnd, true)
window.removeEventListener('transitioncancel', handleSurfaceTransitionEnd, true)
if (presentationSpace === 'scroll') {
window.removeEventListener('wheel', handleScrollIntent, true)
window.removeEventListener('touchmove', handleScrollIntent, true)
window.removeEventListener('keydown', handleScrollIntent, true)
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('scrollend', handleScrollEnd)
}
options.canvas.value?.removeEventListener('webglcontextlost', handleContextLost)
options.canvas.value?.removeEventListener('webglcontextrestored', handleContextRestored)
}
function disposeRenderer(releaseContext = true) {
resumeVersion += 1
loadVersion += 1
prepareVersion += 1
contextRecoveryPending = false
interactionAnimating = false
cancelScheduledFrame()
cancelScrollFrame()
cancelWallpaperTransitionFrame()
cancelSurfaceTransformFrame()
clearBackgroundDisposeTimer()
if (surfaceUpdateFrame !== null) {
cancelAnimationFrame(surfaceUpdateFrame)
surfaceUpdateFrame = null
}
if (surfaceStabilityFrame !== null) {
cancelAnimationFrame(surfaceStabilityFrame)
surfaceStabilityFrame = null
}
if (presentationResizeTimer !== null) {
window.clearTimeout(presentationResizeTimer)
presentationResizeTimer = null
}
presentationResizeCandidate = ''
presentationResizeStableSamples = 0
removeEvents()
resizeObserver?.disconnect()
resizeObserver = null
surfaceMutationObserver?.disconnect()
surfaceMutationObserver = null
observedSurfaces = []
surfaceRegistry = []
availableSurfaces = []
surfaceSlots = []
interactionClips = []
interactionClipRegistry = []
interactionClipMembershipDirty = true
activeSurface = null
activeInteractionClip = null
outgoingSurface = null
surfaceTransitionStartedAt = 0
wakeDirection = { x: 0, y: -1 }
document.documentElement.removeAttribute('data-glass-wallpaper-loading')
activeTouchIdentifier = null
if (presentationSpace === 'scroll') {
scrollWallpaperSamplingSuppressed = false
document.documentElement.removeAttribute('data-glass-scroll-presentation')
}
lastPointerX = window.innerWidth * 0.5
lastPointerY = window.innerHeight * 0.5
pointerTargetX = 0.5
pointerTargetY = 0.5
pointerPositionX = 0.5
pointerPositionY = 0.5
pendingFlowInjection = 0
lastInteractionFrameAt = 0
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = null
previousFrostedTarget = null
previousTextureHeight = 1
previousTextureWidth = 1
previousWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
wallpaperTransitionStartedAt = 0
wallpaperActivationRollback = null
disposeWallpaperResources(activeTexture, activeFrostedTarget)
activeTexture = null
activeFrostedTarget = null
activeTextureHeight = 1
activeTextureWidth = 1
activeHasWallpaperTexture = false
activeWallpaperExposure = DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure
activeWallpaperUrl.value = ''
activeWallpaperRevision.value = 0
activeWallpaperPreparationKey.value = ''
releasePreparedWallpaper()
clearPreparedWallpaperFailure()
disposeFlowResources()
disposeFrostPrefilterResources()
if (resources) {
resources.geometry.dispose()
resources.material.dispose()
resources.renderer.dispose()
if (releaseContext) resources.renderer.forceContextLoss()
resources = null
}
delete document.documentElement.dataset.glassRendererState
}
/** 只有当前加载代次可以处置共享 renderer过期请求的失败不会覆盖新状态。 */
function fallbackFromCurrentLoad(version: number, message: string, error: unknown) {
if (version !== loadVersion) return
console.warn(message, error)
disposeRenderer()
updateRendererState('fallback')
}
/** 后台替换活动纹理;已有纹理在加载失败或完成前继续保持可交互。 */
async function refreshWallpaper(message: string, beforeActivate?: () => void) {
const version = ++loadVersion
const retainsActiveTexture = Boolean(resources && activeTexture)
if (!retainsActiveTexture) updateRendererState('loading')
document.documentElement.setAttribute('data-glass-wallpaper-loading', 'true')
try {
await loadWallpaper(toValue(options.wallpaperUrl), version, beforeActivate)
} catch (error) {
if (version !== loadVersion) return
if (retainsActiveTexture && resources && activeTexture) {
console.warn(message, error)
updateRendererState('ready')
scheduleFrame()
return
}
fallbackFromCurrentLoad(version, message, error)
} finally {
if (version === loadVersion) document.documentElement.removeAttribute('data-glass-wallpaper-loading')
}
}
/** 在同一 renderer 内交接新旧纹理;只有真实壁纸轮换才启用双纹理时钟。 */
function activateLoadedTexture(
texture: Texture,
frostedTarget: WebGLRenderTarget | null,
width: number,
height: number,
hasWallpaperTexture: boolean,
toneProfile: GlassWallpaperToneProfile,
transitionStartedAt = toValue(options.transitionStartedAt ?? 0),
forceTransition = false,
) {
if (!resources) {
disposeWallpaperResources(texture, frostedTarget)
return
}
const hasActiveTransition =
Boolean(activeTexture) &&
transitionStartedAt > 0 &&
(forceTransition || Boolean(toValue(options.previousWallpaperUrl ?? '')))
cancelWallpaperTransitionFrame()
if (hasActiveTransition && activeTexture) {
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
previousTexture = activeTexture
previousFrostedTarget = activeFrostedTarget
previousTextureHeight = activeTextureHeight
previousTextureWidth = activeTextureWidth
previousWallpaperExposure = activeWallpaperExposure
activeTexture = texture
activeFrostedTarget = frostedTarget
activeTextureHeight = height
activeTextureWidth = width
activeWallpaperExposure = toneProfile.exposure
resources.uniforms.uPreviousTexture.value = previousTexture
resources.uniforms.uPreviousFrostedTexture.value = previousFrostedTarget?.texture ?? previousTexture
resources.uniforms.uTexture.value = activeTexture
resources.uniforms.uFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uPreviousWallpaperExposure.value = previousWallpaperExposure
resources.uniforms.uWallpaperExposure.value = activeWallpaperExposure
wallpaperTransitionStartedAt = transitionStartedAt
resources.uniforms.uTextureMix.value = forceTransition
? 0
: getGlassWallpaperTransitionProgress(
performance.now() - wallpaperTransitionStartedAt,
toValue(options.transitionDuration ?? 0),
)
syncCoverScale()
scheduleWallpaperTransition()
} else {
if (previousTexture && previousTexture !== activeTexture) {
disposeWallpaperResources(previousTexture, previousFrostedTarget)
}
disposeWallpaperResources(activeTexture, activeFrostedTarget)
previousTexture = null
previousFrostedTarget = null
activeTexture = texture
activeFrostedTarget = frostedTarget
activeTextureHeight = height
activeTextureWidth = width
activeWallpaperExposure = toneProfile.exposure
previousTextureHeight = height
previousTextureWidth = width
previousWallpaperExposure = toneProfile.exposure
resources.uniforms.uPreviousTexture.value = texture
resources.uniforms.uPreviousFrostedTexture.value = frostedTarget?.texture ?? texture
resources.uniforms.uTexture.value = texture
resources.uniforms.uFrostedTexture.value = frostedTarget?.texture ?? texture
resources.uniforms.uPreviousWallpaperExposure.value = toneProfile.exposure
resources.uniforms.uWallpaperExposure.value = toneProfile.exposure
resources.uniforms.uTextureMix.value = 1
wallpaperTransitionStartedAt = 0
wallpaperActivationRollback = null
syncCoverScale()
}
activeHasWallpaperTexture = hasWallpaperTexture
syncWallpaperSamplingMode()
resources.uniforms.uHasFrostedTexture.value = hasWallpaperTexture && frostedTarget ? 1 : 0
}
/** 解码并按当前质量预算缩放壁纸,不改变当前可见纹理。 */
async function createWallpaperTexture(url: string): Promise<PreparedWallpaperTexture | null> {
if (!resources || !three || !url) return null
const appearance = toValue(options.appearance)
const quality = toValue(options.quality)
const routeKey = toValue(options.routeKey)
const profile = getGlassOpticalRenderProfile(quality, routeKey)
const preparationKey = getGlassWallpaperPreparationKey(appearance, quality, routeKey, url)
const shouldUseProceduralTexture =
profile.textureSource === 'procedural' ||
(profile.textureSource === 'auto' && !canUseGlassWallpaperTexture(url, window.location.href))
if (shouldUseProceduralTexture) {
const textureCanvas = document.createElement('canvas')
textureCanvas.width = 1
textureCanvas.height = 1
const texture = new three.CanvasTexture(textureCanvas)
texture.colorSpace = three.SRGBColorSpace
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
return {
frostedTarget: null,
hasWallpaperTexture: false,
height: 1,
preparationKey,
texture,
toneProfile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
width: 1,
}
}
const { textureLimit } = profile
const preparesFrostedTexture = appearance === 'frosted'
// 源纹理只需覆盖各质量档的最大有效输出足迹;更高分辨率会重复占用两个 context 的上传与显存。
const sourceLimit = quality === 'high' ? WALLPAPER_SOURCE_LIMIT_HIGH : WALLPAPER_SOURCE_LIMIT_BALANCED
const cacheKey = `${sourceLimit}:${url}`
recordGlassRendererTiming(presentationSpace, 'source-requested')
const source = await wallpaperSourceCache.get(cacheKey, async () => {
const tone = await loadGlassWallpaperTone(url)
const decodedSource = tone.corsReady ? takeGlassWallpaperDecodedSource(url) : undefined
let sourceTexture: Texture | null = null
let image = decodedSource?.image
if (!image) {
const loader = new three!.TextureLoader()
loader.setCrossOrigin('anonymous')
sourceTexture = await loader.loadAsync(url)
image = sourceTexture.image as HTMLImageElement
}
const sourceWidth = image.naturalWidth || image.width
const sourceHeight = image.naturalHeight || image.height
const sourceLongEdge = Math.max(sourceWidth, sourceHeight)
const uploadLongEdge = Math.min(sourceLongEdge, textureLimit, sourceLimit)
const scale = Math.min(1, uploadLongEdge / sourceLongEdge)
const textureCanvas = document.createElement('canvas')
textureCanvas.width = Math.max(1, Math.round(sourceWidth * scale))
textureCanvas.height = Math.max(1, Math.round(sourceHeight * scale))
const textureContext = textureCanvas.getContext('2d')
const preparedImage = textureContext ? textureCanvas : image
if (textureContext) textureContext.drawImage(image, 0, 0, textureCanvas.width, textureCanvas.height)
sourceTexture?.dispose()
return {
height: textureContext ? textureCanvas.height : sourceHeight,
image: preparedImage,
toneProfile:
decodedSource?.profile ?? tone.profile ?? analyzeGlassWallpaperTone(image, sourceWidth, sourceHeight),
width: textureContext ? textureCanvas.width : sourceWidth,
}
})
recordGlassRendererTiming(presentationSpace, 'source-ready')
const texture = new three.Texture(source.image)
texture.needsUpdate = true
const textureWidth = source.width
const textureHeight = source.height
texture.colorSpace = three.SRGBColorSpace
texture.generateMipmaps = false
texture.minFilter = three.LinearFilter
texture.magFilter = three.LinearFilter
let frostedTarget: WebGLRenderTarget | null = null
try {
if (preparesFrostedTexture) {
recordGlassRendererTiming(presentationSpace, 'prefilter-start')
frostedTarget = await createFrostedWallpaperTarget(
texture,
textureWidth,
textureHeight,
Math.max(textureWidth, textureHeight) * FROST_PREFILTER_SCALE,
)
recordGlassRendererTiming(presentationSpace, 'prefilter-ready')
}
} catch (error) {
// 预滤属于磨砂优化;失败时保留源纹理并退回既有扩散采样,不能拖垮其他材质。
console.warn('玻璃磨砂壁纸预滤失败,继续使用实时扩散采样:', error)
}
return {
frostedTarget,
hasWallpaperTexture: true,
height: textureHeight,
preparationKey,
texture,
toneProfile: source.toneProfile,
width: textureWidth,
}
}
/** 提前准备下一张纹理;失败不会影响当前活动纹理。 */
async function prepareWallpaper(url: string, revision: number) {
const version = ++prepareVersion
releasePreparedWallpaper()
clearPreparedWallpaperFailure()
if (!url || !resources || contextRecoveryPending || url === toValue(options.wallpaperUrl)) return
const preparationKey = getWallpaperPreparationKey(url)
try {
const prepared = await createWallpaperTexture(url)
if (!prepared) return
if (
version !== prepareVersion ||
!resources ||
contextRecoveryPending ||
preparationKey !== prepared.preparationKey ||
preparationKey !== getWallpaperPreparationKey(url)
) {
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
resources.renderer.initTexture(prepared.texture)
recordGlassRendererTiming(presentationSpace, 'prepare-compile-start')
await resources.renderer.compileAsync(resources.scene, resources.camera)
recordGlassRendererTiming(presentationSpace, 'prepare-compile-ready')
if (
version !== prepareVersion ||
!resources ||
contextRecoveryPending ||
preparationKey !== getWallpaperPreparationKey(url)
) {
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
preparedWallpaper = prepared
preparedWallpaperUrl.value = url
preparedWallpaperRevision.value = revision
preparedWallpaperPreparationKey.value = preparationKey
} catch (error) {
if (version === prepareVersion) {
failedWallpaperUrl.value = url
failedWallpaperRevision.value = revision
failedWallpaperPreparationKey.value = preparationKey
console.warn('玻璃光学壁纸预备失败,继续使用当前纹理:', error)
}
}
}
async function loadWallpaper(url: string, version: number, beforeActivate?: () => void) {
if (!resources || !three || !url) return
const prepared = await createWallpaperTexture(url)
if (!prepared) return
if (
version !== loadVersion ||
!resources ||
contextRecoveryPending ||
prepared.preparationKey !== getWallpaperPreparationKey(url)
) {
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
recordGlassRendererTiming(presentationSpace, 'compile-start')
await resources.renderer.compileAsync(resources.scene, resources.camera)
recordGlassRendererTiming(presentationSpace, 'compile-ready')
if (
version !== loadVersion ||
!resources ||
contextRecoveryPending ||
prepared.preparationKey !== getWallpaperPreparationKey(url)
) {
disposeWallpaperResources(prepared.texture, prepared.frostedTarget)
return
}
const timestamp = performance.now()
beforeActivate?.()
activateLoadedTexture(
prepared.texture,
prepared.frostedTarget,
prepared.width,
prepared.height,
prepared.hasWallpaperTexture,
prepared.toneProfile,
)
updateSurfaceUniforms(timestamp, false)
resetInteractionState()
renderFrame(timestamp, false)
activeWallpaperUrl.value = url
activeWallpaperRevision.value = 0
activeWallpaperPreparationKey.value = prepared.preparationKey
updateRendererState('ready')
recordGlassRendererTiming(presentationSpace, 'ready')
scheduleWallpaperTransition()
}
/** 只读校验由 Layer 执行,两个 context 均通过后才允许进入同一绘制帧提交。 */
function canActivatePreparedWallpaper(url: string, revision: number, preparationKey: string) {
return Boolean(
resources &&
!contextRecoveryPending &&
state.value === 'ready' &&
preparedWallpaper &&
preparedWallpaperUrl.value === url &&
preparedWallpaperRevision.value === revision &&
preparedWallpaperPreparationKey.value === preparationKey &&
preparationKey === getWallpaperPreparationKey(url),
)
}
/** 在共享 rAF 内同步消费当前 context 的 prepared bundle并以统一时钟绘制首个过渡帧。 */
function activatePreparedWallpaper(url: string, revision: number, preparationKey: string, startedAt: number) {
if (!canActivatePreparedWallpaper(url, revision, preparationKey) || !preparedWallpaper) return false
const rollback = {
activatedRevision: revision,
activatedUrl: url,
previousHasWallpaperTexture: activeHasWallpaperTexture,
previousPreparationKey: activeWallpaperPreparationKey.value,
previousRevision: activeWallpaperRevision.value,
previousUrl: activeWallpaperUrl.value,
}
const prepared = preparedWallpaper
preparedWallpaper = null
preparedWallpaperUrl.value = ''
preparedWallpaperRevision.value = 0
preparedWallpaperPreparationKey.value = ''
loadVersion += 1
activateLoadedTexture(
prepared.texture,
prepared.frostedTarget,
prepared.width,
prepared.height,
prepared.hasWallpaperTexture,
prepared.toneProfile,
startedAt,
true,
)
wallpaperActivationRollback = previousTexture ? rollback : null
activeWallpaperUrl.value = url
activeWallpaperRevision.value = revision
activeWallpaperPreparationKey.value = preparationKey
updateSurfaceUniforms(startedAt, false)
resetInteractionState()
renderFrame(startedAt, false)
updateRendererState('ready')
recordGlassRendererTiming(presentationSpace, 'active')
scheduleWallpaperTransition()
return true
}
/**
* 撤销刚完成但尚未由父层确认的 prepared 激活。
* 旧活动纹理仍在 previous 槽中,因此失败恢复不需要重新下载或上传。
*/
function rollbackPreparedWallpaperActivation(url: string, revision: number) {
const rollback = wallpaperActivationRollback
if (
!resources ||
!activeTexture ||
!previousTexture ||
!rollback ||
rollback.activatedUrl !== url ||
rollback.activatedRevision !== revision ||
activeWallpaperUrl.value !== url ||
activeWallpaperRevision.value !== revision
) {
return false
}
cancelWallpaperTransitionFrame()
const rejectedTexture = activeTexture
const rejectedFrostedTarget = activeFrostedTarget
activeTexture = previousTexture
activeFrostedTarget = previousFrostedTarget
activeTextureHeight = previousTextureHeight
activeTextureWidth = previousTextureWidth
activeWallpaperExposure = previousWallpaperExposure
previousTexture = null
previousFrostedTarget = null
previousTextureHeight = activeTextureHeight
previousTextureWidth = activeTextureWidth
previousWallpaperExposure = activeWallpaperExposure
wallpaperTransitionStartedAt = 0
wallpaperActivationRollback = null
activeWallpaperUrl.value = rollback.previousUrl
activeWallpaperRevision.value = rollback.previousRevision
activeWallpaperPreparationKey.value = rollback.previousPreparationKey
resources.uniforms.uTexture.value = activeTexture
resources.uniforms.uFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uPreviousTexture.value = activeTexture
resources.uniforms.uPreviousFrostedTexture.value = activeFrostedTarget?.texture ?? activeTexture
resources.uniforms.uWallpaperExposure.value = activeWallpaperExposure
resources.uniforms.uPreviousWallpaperExposure.value = activeWallpaperExposure
resources.uniforms.uTextureMix.value = 1
activeHasWallpaperTexture = rollback.previousHasWallpaperTexture
syncWallpaperSamplingMode()
resources.uniforms.uHasFrostedTexture.value = rollback.previousHasWallpaperTexture && activeFrostedTarget ? 1 : 0
syncCoverScale()
resetInteractionState()
disposeWallpaperResources(rejectedTexture, rejectedFrostedTarget)
renderFrame(performance.now(), false)
return true
}
async function initializeRenderer(releaseContext = true) {
recordGlassRendererTiming(presentationSpace, 'initialize-start')
disposeRenderer(releaseContext)
if (!toValue(options.active) || !options.canvas.value) return
if (!window.WebGLRenderingContext || reducedTransparencyQuery.matches || !toValue(options.wallpaperUrl)) {
updateRendererState('fallback')
return
}
updateRendererState('loading')
const version = ++loadVersion
try {
three = await import('three')
recordGlassRendererTiming(presentationSpace, 'three-ready')
if (version !== loadVersion || !options.canvas.value) return
const Vector4Class = three.Vector4
const canvas = options.canvas.value
const context = prepareGlassWebGLContext(canvas)
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: false,
canvas,
...(context ? { context } : {}),
powerPreference: 'high-performance',
premultipliedAlpha: true,
})
recordGlassRendererTiming(presentationSpace, 'context-ready')
renderer.setClearColor(0x000000, 0)
renderer.setPixelRatio(1)
const geometry = new three.BufferGeometry()
geometry.setAttribute('position', new three.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3))
const materialResponse = getMaterialResponse()
const uniforms: GlassRendererUniforms = {
uAppearance: { value: getGlassAppearanceUniformValue(toValue(options.appearance)) },
uBackgroundVisibility: { value: materialResponse.backgroundVisibility },
uCoverScale: { value: new three.Vector2(1, 1) },
uDeformationStrength: { value: getDeformationStrengthScale() },
uDynamicsOnly: { value: presentationSpace === 'scroll' ? 1 : 0 },
uFlowTexture: { value: null },
uFlowStrength: { value: getFlowStrengthScale() },
uHasFlowTexture: { value: 0 },
uHasFrostedTexture: { value: 0 },
uHasWallpaperTexture: { value: 0 },
uInteractionRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uInteractionRectCount: { value: 0 },
uInteractionRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uMotion: { value: 0 },
uMotionExpansion: { value: getMotionExpansion() },
uMaxRefractionPixels: { value: getMaxRefractionPixels() },
uPointer: { value: new three.Vector2(0.5, 0.5) },
uPointerVelocity: { value: new three.Vector2(0, 0) },
uPresentationSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
uPreviousCoverScale: { value: new three.Vector2(1, 1) },
uPreviousWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
uQuality: { value: toValue(options.quality) === 'high' ? 1 : 0 },
uReflectionStrength: { value: getReflectionStrengthScale() },
uRadii: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uRectCount: { value: 0 },
uRects: { value: Array.from({ length: 8 }, () => new Vector4Class()) },
uSurfaceWeights: { value: Array.from({ length: 8 }, () => 0) },
uSurfaceDynamics: { value: Array.from({ length: 8 }, () => 1) },
uPreviousTexture: { value: null },
uPreviousFrostedTexture: { value: null },
uTexture: { value: null },
uFrostedTexture: { value: null },
uTextureMix: { value: 1 },
uWallpaperExposure: { value: DEFAULT_GLASS_WALLPAPER_TONE_PROFILE.exposure },
uTintColor: { value: new three.Color(toValue(options.tintColor)) },
uFrostDetailLevel: { value: materialResponse.frostDetailLevel },
uSurfaceDensity: { value: materialResponse.surfaceDensity },
uTintDensity: { value: materialResponse.tintDensity },
uTransmissionStrength: {
value: getGlassOpticalTransmissionStrength(
toValue(options.transmissionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
),
},
uTranslationStrength: { value: getTranslationStrengthScale() },
uTrail: { value: Array.from({ length: 4 }, () => new Vector4Class(0.5, 0.5, 0, 0)) },
uTrailCount: { value: getRenderProfile().trailCount },
uVisibleViewportSize: { value: new three.Vector2(window.innerWidth, window.innerHeight) },
uScrollOffset: { value: new three.Vector2(0, 0) },
uWakeDirection: { value: new three.Vector2(0, -1) },
}
const material = new three.ShaderMaterial({
depthTest: false,
depthWrite: false,
fragmentShader: FRAGMENT_SHADER,
transparent: true,
uniforms,
vertexShader: VERTEX_SHADER,
})
const scene = new three.Scene()
const camera = new three.OrthographicCamera(-1, 1, 1, -1, 0, 1)
const mesh = new three.Mesh(geometry, material)
mesh.frustumCulled = false
scene.add(mesh)
resources = { camera, geometry, material, mesh, renderer, scene, uniforms }
syncFlowResources()
setupObservers()
setupEvents()
resizeRenderer()
await loadWallpaper(toValue(options.wallpaperUrl), version)
preparePendingWallpaper()
} catch (error) {
fallbackFromCurrentLoad(version, '玻璃光学渲染器初始化失败,已回退标准材质:', error)
}
}
/** 运行中切换减少透明度时立即释放或恢复光学资源。 */
function handleReducedTransparencyChange(event: MediaQueryListEvent) {
if (!toValue(options.active)) return
if (event.matches) {
disposeRenderer()
updateRendererState('fallback')
return
}
void initializeRenderer()
}
const reducedTransparencyQuery = matchMedia('(prefers-reduced-transparency: reduce)')
reducedTransparencyQuery.addEventListener('change', handleReducedTransparencyChange)
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('blur', handleWindowBlur)
window.addEventListener('focus', handleWindowResume)
window.addEventListener('pageshow', handleWindowResume)
watch(
() => [toValue(options.active), toValue(options.wallpaperUrl)] as const,
async ([active, wallpaperUrl], previous) => {
if (!active) {
disposeRenderer()
return
}
if (resources && wallpaperUrl !== previous?.[1]) {
if (
activeWallpaperUrl.value === wallpaperUrl &&
activeWallpaperPreparationKey.value === getWallpaperPreparationKey(wallpaperUrl)
) {
scheduleWallpaperTransition()
return
}
await refreshWallpaper('玻璃光学壁纸纹理加载失败,已保留当前材质:')
return
}
if (!resources) {
await nextTick()
await initializeRenderer()
}
},
{ immediate: true },
)
watch(
() => [toValue(options.pendingWallpaperUrl ?? ''), toValue(options.pendingWallpaperRevision ?? 0)] as const,
([pendingWallpaperUrl, pendingWallpaperRevision]) => {
void prepareWallpaper(pendingWallpaperUrl, pendingWallpaperRevision)
},
{ immediate: true },
)
watch(
() => toValue(options.previousWallpaperUrl ?? ''),
previousWallpaperUrl => {
if (previousWallpaperUrl || !resources || !activeTexture) return
// 外层 previous 槽清空即代表共享切换事务完成;双纹理必须在下一笔输入前同步归一。
finishWallpaperTransition()
renderFrame(performance.now(), false)
},
{ flush: 'sync' },
)
watch(
() => toValue(options.appearance),
async (appearance, previousAppearance) => {
if (!resources) return
const applyAppearance = () => {
if (!resources || toValue(options.appearance) !== appearance) return
const materialResponse = getGlassMaterialResponse(
appearance,
toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
)
resources.uniforms.uAppearance.value = getGlassAppearanceUniformValue(appearance)
resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility
resources.uniforms.uFrostDetailLevel.value = materialResponse.frostDetailLevel
resources.uniforms.uSurfaceDensity.value = materialResponse.surfaceDensity
resources.uniforms.uTintDensity.value = materialResponse.tintDensity
}
const wallpaperUrl = toValue(options.wallpaperUrl)
const quality = toValue(options.quality)
const routeKey = toValue(options.routeKey)
const previousKey = getGlassWallpaperPreparationKey(
previousAppearance ?? appearance,
quality,
routeKey,
wallpaperUrl,
)
const nextKey = getGlassWallpaperPreparationKey(appearance, quality, routeKey, wallpaperUrl)
if (previousKey !== nextKey) {
invalidatePreparedWallpaper()
await refreshWallpaper('玻璃材质纹理切换失败,已保留当前材质:', applyAppearance)
if (toValue(options.appearance) === appearance) preparePendingWallpaper()
return
}
applyAppearance()
scheduleSurfaceStabilityUpdate()
},
)
watch(
() => toValue(options.quality),
async (quality, previousQuality) => {
if (!resources) return
const previousProfile = getGlassOpticalRenderProfile(previousQuality, toValue(options.routeKey))
const nextProfile = getGlassOpticalRenderProfile(quality, toValue(options.routeKey))
const applyQuality = () => {
if (!resources || toValue(options.quality) !== quality) return
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels(
nextProfile.maxRefractionPixels,
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
)
resources.uniforms.uQuality.value = quality === 'high' ? 1 : 0
resources.uniforms.uTrailCount.value = nextProfile.trailCount
interactionAnimating = false
cancelScheduledFrame()
resetInteractionState()
syncFlowResources()
resizeRenderer()
}
if (!profileRequiresTextureReload(previousProfile, nextProfile)) {
applyQuality()
scheduleFrame()
return
}
invalidatePreparedWallpaper()
await refreshWallpaper('玻璃光学质量切换失败,已保留当前材质:', applyQuality)
if (toValue(options.quality) === quality) preparePendingWallpaper()
},
)
watch(
() => toValue(options.tintColor),
tintColor => {
if (!resources) return
resources.uniforms.uTintColor.value.set(tintColor)
scheduleFrame()
},
)
watch(
() =>
[
toValue(options.translationStrength ?? getLegacyDynamicStrength()),
toValue(options.deformationStrength ?? getLegacyDynamicStrength()),
toValue(options.flowStrength ?? getLegacyDynamicStrength()),
toValue(options.reflectionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
toValue(options.transparencyStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
toValue(options.transmissionStrength ?? GLASS_OPTICAL_STRENGTH_DEFAULT),
] as const,
([
translationStrength,
deformationStrength,
flowStrength,
reflectionStrength,
transparencyStrength,
transmissionStrength,
]) => {
if (!resources) return
resources.uniforms.uTranslationStrength.value = getGlassOpticalTranslationStrengthScale(translationStrength)
resources.uniforms.uDeformationStrength.value = getGlassOpticalDeformationStrengthScale(deformationStrength)
resources.uniforms.uFlowStrength.value = getGlassOpticalFlowStrengthScale(flowStrength)
resources.uniforms.uMotionExpansion.value = getGlassOpticalMotionExpansion(flowStrength)
resources.uniforms.uMaxRefractionPixels.value = getGlassOpticalMaxRefractionPixels(
getRenderProfile().maxRefractionPixels,
deformationStrength,
)
resources.uniforms.uReflectionStrength.value = getGlassOpticalReflectionStrengthScale(reflectionStrength)
const materialResponse = getGlassMaterialResponse(toValue(options.appearance), transparencyStrength)
resources.uniforms.uBackgroundVisibility.value = materialResponse.backgroundVisibility
resources.uniforms.uFrostDetailLevel.value = materialResponse.frostDetailLevel
resources.uniforms.uSurfaceDensity.value = materialResponse.surfaceDensity
resources.uniforms.uTintDensity.value = materialResponse.tintDensity
resources.uniforms.uTransmissionStrength.value = getGlassOpticalTransmissionStrength(transmissionStrength)
if (resources.uniforms.uFlowStrength.value <= 0 && interactionAnimating) {
interactionAnimating = false
cancelScheduledFrame()
pendingFlowInjection = 0
for (const trail of resources.uniforms.uTrail.value) trail.z = 0
}
scheduleFrame()
},
)
watch(
() => toValue(options.pageMotion?.revision ?? 0),
() => {
if (!resources || presentationSpace !== 'scroll') return
const timestamp = performance.now()
// 页面入场期间内容高度与 canvas CSS 尺寸可能同帧变化presentation 必须先于表面和清屏提交。
resizeRenderer()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
},
{ flush: 'sync' },
)
watch(
() => toValue(options.routeKey),
async (routeKey, previousRouteKey) => {
const previousProfile = getRenderProfile(previousRouteKey ?? '')
const wallpaperUrl = toValue(options.wallpaperUrl)
const appearance = toValue(options.appearance)
const quality = toValue(options.quality)
const previousKey = getGlassWallpaperPreparationKey(appearance, quality, previousRouteKey ?? '', wallpaperUrl)
const nextKey = getGlassWallpaperPreparationKey(appearance, quality, routeKey, wallpaperUrl)
if (previousKey !== nextKey) invalidatePreparedWallpaper()
if (resources && presentationSpace === 'scroll' && options.pageMotion) {
pagePresentationGeometryReady = false
const timestamp = performance.now()
updateSurfaceUniforms(timestamp, false)
renderFrame(timestamp, false)
}
await nextTick()
if (resources) {
const nextProfile = getRenderProfile(routeKey)
resizeRenderer()
if (profileRequiresTextureReload(previousProfile, nextProfile)) {
await refreshWallpaper('玻璃场景纹理切换失败,已保留当前材质:', resizeRenderer)
if (toValue(options.routeKey) === routeKey) preparePendingWallpaper()
return
}
}
if (presentationSpace === 'scroll' && options.pageMotion) scheduleSurfaceStabilityUpdate()
else scheduleSurfaceUpdate()
},
)
onScopeDispose(() => {
reducedTransparencyQuery.removeEventListener('change', handleReducedTransparencyChange)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('blur', handleWindowBlur)
window.removeEventListener('focus', handleWindowResume)
window.removeEventListener('pageshow', handleWindowResume)
disposeRenderer()
})
return {
activatePreparedWallpaper,
activeWallpaperPreparationKey,
activeWallpaperRevision,
activeWallpaperUrl,
canActivatePreparedWallpaper,
failedWallpaperPreparationKey,
failedWallpaperRevision,
failedWallpaperUrl,
preparedWallpaperPreparationKey,
preparedWallpaperUrl,
preparedWallpaperRevision,
renderedFrames,
rollbackPreparedWallpaperActivation,
state,
}
}