feat(glass): refine material response and rendering lifecycle (#596)

This commit is contained in:
InfinityPacer
2026-07-29 11:19:12 +08:00
committed by GitHub
parent 90d626e7c6
commit 287cbc76ae
29 changed files with 4145 additions and 632 deletions
+170 -9
View File
@@ -3,7 +3,9 @@ import {
GLASS_OPTICAL_MOTION_MAX_SCALE,
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
getAvailableGlassOpticalPresets,
getGlassCssFrostBlur,
getGlassCoverScale,
getGlassMaterialResponse,
getGlassOpticalCssTransmissionBrightness,
getGlassOpticalDecay,
getGlassOpticalBufferSize,
@@ -12,6 +14,8 @@ import {
getGlassOpticalMotionExpansion,
getGlassOpticalMotionStrengthScale,
getGlassOpticalPresetParameters,
getGlassOpticalPresetParametersWithOverrides,
getGlassOpticalPresetKey,
getGlassOpticalReflectionStrengthScale,
getGlassOpticalRenderProfile,
getGlassOpticalTransparency,
@@ -57,7 +61,9 @@ describe('glass optics geometry', () => {
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
)
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
expect(getGlassOpticalMotionExpansion(0)).toBe(0)
expect(getGlassOpticalMotionExpansion(50)).toBeGreaterThan(0.3)
expect(getGlassOpticalMotionExpansion(50)).toBeLessThan(0.4)
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
@@ -87,12 +93,12 @@ describe('glass optics geometry', () => {
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
expect(natural).toEqual({
deformation: 50,
flow: 50,
deformation: 40,
flow: 40,
reflection: 35,
transmission: 70,
translation: 50,
transparency: 70,
transmission: 54,
translation: 40,
transparency: 46,
})
expect(glide.translation).toBeGreaterThan(glide.deformation)
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
@@ -101,11 +107,166 @@ describe('glass optics geometry', () => {
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
})
it('keeps every preset dynamic parameter at the approved material calibration', () => {
const expected = {
clear: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 40, flow: 40, translation: 40 },
glide: { deformation: 24, flow: 35, translation: 58 },
liquid: { deformation: 56, flow: 61, translation: 45 },
},
high: {
natural: { deformation: 40, flow: 40, translation: 40 },
glide: { deformation: 26, flow: 37, translation: 59 },
liquid: { deformation: 59, flow: 64, translation: 46 },
},
},
tinted: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 42, flow: 40, translation: 40 },
glide: { deformation: 26, flow: 35, translation: 56 },
liquid: { deformation: 58, flow: 61, translation: 45 },
},
high: {
natural: { deformation: 42, flow: 40, translation: 40 },
glide: { deformation: 27, flow: 37, translation: 58 },
liquid: { deformation: 61, flow: 64, translation: 46 },
},
},
frosted: {
css: { natural: { deformation: 40, flow: 40, translation: 40 } },
balanced: {
natural: { deformation: 46, flow: 42, translation: 38 },
glide: { deformation: 30, flow: 35, translation: 54 },
liquid: { deformation: 62, flow: 61, translation: 42 },
},
high: {
natural: { deformation: 48, flow: 42, translation: 38 },
glide: { deformation: 32, flow: 37, translation: 56 },
liquid: { deformation: 66, flow: 64, translation: 43 },
},
},
} as const
for (const [appearance, qualities] of Object.entries(expected)) {
for (const [quality, presets] of Object.entries(qualities)) {
for (const [preset, parameters] of Object.entries(presets)) {
expect(
getGlassOpticalPresetParameters(
appearance as 'clear' | 'frosted' | 'tinted',
quality as 'balanced' | 'css' | 'high',
preset as 'glide' | 'liquid' | 'natural',
),
).toMatchObject(parameters as object)
}
}
}
})
it('restores per-combination overrides and keeps standard quality on natural', () => {
const key = getGlassOpticalPresetKey('tinted', 'high', 'glide')
const override = {
deformation: 11,
flow: 22,
reflection: 33,
transmission: 44,
translation: 55,
transparency: 66,
}
expect(key).toBe('tinted:high:glide')
expect(getGlassOpticalPresetKey('frosted', 'css', 'liquid')).toBe('frosted:css:natural')
expect(getGlassOpticalPresetParametersWithOverrides('tinted', 'high', 'glide', { [key]: override })).toEqual(
override,
)
expect(getGlassOpticalPresetParametersWithOverrides('clear', 'balanced', 'natural', {})).toEqual(
getGlassOpticalPresetParameters('clear', 'balanced', 'natural'),
)
})
it('uses the approved transparency and transmission matrix for all effective presets', () => {
const expected = {
clear: {
css: { natural: [48, 56] },
balanced: { natural: [46, 54], glide: [56, 58], liquid: [51, 51] },
high: { natural: [45, 53], glide: [54, 56], liquid: [50, 50] },
},
tinted: {
css: { natural: [34, 54] },
balanced: { natural: [32, 56], glide: [40, 61], liquid: [36, 53] },
high: { natural: [30, 54], glide: [38, 59], liquid: [34, 51] },
},
frosted: {
css: { natural: [31, 50] },
balanced: { natural: [29, 52], glide: [40, 56], liquid: [34, 49] },
high: { natural: [27, 50], glide: [38, 54], liquid: [32, 47] },
},
} as const
for (const [appearance, qualities] of Object.entries(expected)) {
for (const [quality, presets] of Object.entries(qualities)) {
for (const [preset, values] of Object.entries(presets)) {
const [transparency, transmission] = values as readonly [number, number]
expect(
getGlassOpticalPresetParameters(
appearance as 'clear' | 'frosted' | 'tinted',
quality as 'balanced' | 'css' | 'high',
preset as 'glide' | 'liquid' | 'natural',
),
).toMatchObject({ transmission, transparency })
}
}
}
})
it('derives independent material responses from piecewise smooth transparency anchors', () => {
expect(getGlassMaterialResponse('clear', 0)).toMatchObject({
backgroundVisibility: 0.18,
surfaceDensity: 1,
})
expect(getGlassMaterialResponse('tinted', 50)).toMatchObject({
backgroundVisibility: 0.48,
tintDensity: 0.65,
})
const frostedLow = getGlassMaterialResponse('frosted', 20)
expect(frostedLow).toMatchObject({
backgroundVisibility: 0.14,
frostBlurScale: 1.48,
surfaceDensity: 0.96,
})
expect(frostedLow.frostDetailLevel).toBeCloseTo(0.1)
expect(getGlassMaterialResponse('frosted', 100)).toMatchObject({
backgroundVisibility: 0.88,
frostBlurScale: 0.52,
frostDetailLevel: 0.9,
surfaceDensity: 0.4,
})
expect(getGlassCssFrostBlur(0)).toEqual({ raised: 84, surface: 64 })
expect(getGlassCssFrostBlur(50)).toEqual({ raised: 62, surface: 44 })
expect(getGlassCssFrostBlur(100)).toEqual({ raised: 26, surface: 16 })
const samples = [0, 10, 20, 35, 50, 60, 70, 78, 85, 92, 100].map(value =>
getGlassMaterialResponse('frosted', value),
)
expect(
samples.every(
(sample, index) =>
index === 0 ||
(sample.backgroundVisibility > samples[index - 1].backgroundVisibility &&
sample.frostDetailLevel > samples[index - 1].frostDetailLevel &&
sample.surfaceDensity < samples[index - 1].surfaceDensity),
),
).toBe(true)
})
it('returns preset copies so previews cannot mutate the shared matrix', () => {
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
first.translation = 0
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(72)
expect(getGlassOpticalPresetParameters('tinted', 'high', 'glide').translation).toBe(58)
})
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
@@ -346,8 +507,8 @@ describe('glass optics geometry', () => {
})
it('crossfades active surface weights monotonically', () => {
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 0.35, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 0.675, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(0, 96)).toEqual({ incoming: 1, outgoing: 1 })
expect(getGlassOpticalSurfaceTransitionWeights(48, 96)).toEqual({ incoming: 1, outgoing: 0.5 })
expect(getGlassOpticalSurfaceTransitionWeights(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
})
+135 -2
View File
@@ -1,7 +1,17 @@
import { DEFAULT_GLASS_WALLPAPER_TONE_PROFILE, getGlassWallpaperToneProfile } from '@/utils/glassWallpaperTone'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
getGlassWallpaperToneProfile,
loadGlassWallpaperTone,
takeGlassWallpaperDecodedSource,
} from '@/utils/glassWallpaperTone'
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('glass wallpaper tone profile', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps a representative mid-tone wallpaper near neutral exposure', () => {
const profile = getGlassWallpaperToneProfile([0.18, 0.3, 0.36, 0.4, 0.52, 0.72, 0.82])
@@ -32,4 +42,127 @@ describe('glass wallpaper tone profile', () => {
it('falls back to the neutral profile when no valid samples exist', () => {
expect(getGlassWallpaperToneProfile([Number.NaN])).toEqual(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
})
it('reuses the successful CORS image decode for readiness and tone analysis', async () => {
const pixels = new Uint8ClampedArray(64 * 64 * 4)
for (let offset = 0; offset < pixels.length; offset += 4) {
pixels[offset] = 128
pixels[offset + 1] = 128
pixels[offset + 2] = 128
pixels[offset + 3] = 255
}
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: pixels })),
} as unknown as CanvasRenderingContext2D)
const fetchMock = vi.fn()
let imageCount = 0
class SuccessfulImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
queueMicrotask(() => this.onload?.())
}
}
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('Image', SuccessfulImage)
const result = await loadGlassWallpaperTone('https://image.example/success.jpg')
expect(result.corsReady).toBe(true)
expect(result.profile.medianLuminance).toBeCloseTo(128 / 255)
expect(imageCount).toBe(1)
expect(fetchMock).not.toHaveBeenCalled()
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')?.profile).toEqual(result.profile)
expect(takeGlassWallpaperDecodedSource('https://image.example/success.jpg')).toBeUndefined()
})
it('repairs a polluted browser cache before retrying the CORS image decode', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
} as unknown as CanvasRenderingContext2D)
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockResolvedValueOnce({
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
ok: true,
})
let imageCount = 0
class RecoverableImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
const succeeds = imageCount > 1
queueMicrotask(() => (succeeds ? this.onload?.() : this.onerror?.()))
}
}
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('Image', RecoverableImage)
const result = await loadGlassWallpaperTone('https://image.example/recovered.jpg')
expect(result.corsReady).toBe(true)
expect(imageCount).toBe(2)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenLastCalledWith(
new URL('https://image.example/recovered.jpg'),
expect.objectContaining({ cache: 'reload', credentials: 'omit', mode: 'cors' }),
)
})
it('retries a transiently failed CORS decode instead of caching the fallback', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn(),
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray([128, 128, 128, 255]) })),
} as unknown as CanvasRenderingContext2D)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
blob: vi.fn().mockResolvedValue(new Blob()),
ok: false,
}),
)
let imageCount = 0
class TransientImage {
crossOrigin = ''
decoding = ''
height = 64
naturalHeight = 64
naturalWidth = 64
onerror: (() => void) | null = null
onload: (() => void) | null = null
width = 64
set src(_value: string) {
imageCount += 1
queueMicrotask(() => (imageCount === 1 ? this.onerror?.() : this.onload?.()))
}
}
vi.stubGlobal('Image', TransientImage)
const failed = await loadGlassWallpaperTone('https://image.example/transient.jpg')
const recovered = await loadGlassWallpaperTone('https://image.example/transient.jpg')
expect(failed.corsReady).toBe(false)
expect(recovered.corsReady).toBe(true)
expect(imageCount).toBe(2)
})
})
+125 -40
View File
@@ -13,6 +13,8 @@ export const GLASS_OPTICAL_REFERENCE_STRENGTH = 70
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
export type GlassOpticalPresetKey = `${GlassAppearance}:${GlassOpticalCapability}:${GlassOpticalPreset}`
export type GlassOpticalPresetOverrides = Partial<Record<GlassOpticalPresetKey, GlassOpticalParameters>>
export type GlassOpticalQuality = 'balanced' | 'high'
export type GlassCornerRadii = [number, number, number, number]
@@ -31,6 +33,19 @@ export interface GlassOpticalParameters {
transparency: number
}
export interface GlassMaterialResponse {
/** 真实壁纸在表面材质后的感知可见程度。 */
backgroundVisibility: number
/** 磨砂预滤纹理允许保留的高频细节比例。 */
frostDetailLevel: number
/** 标准档 CSS 磨砂半径相对既有 40px 基线的缩放。 */
frostBlurScale: number
/** 表面遮罩对真实壁纸的覆盖密度。 */
surfaceDensity: number
/** 色调材质的主体染色密度。 */
tintDensity: number
}
export interface GlassInteractionPoint {
/** 指针或触点相对视口的横坐标。 */
x: number
@@ -128,59 +143,57 @@ export function normalizeGlassOpticalStrength(value: unknown) {
return Math.min(GLASS_OPTICAL_STRENGTH_MAX, Math.max(GLASS_OPTICAL_STRENGTH_MIN, Math.round(value)))
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<
GlassAppearance,
Record<GlassOpticalCapability, Record<GlassOpticalPreset, GlassOpticalParameters>>
> = {
type GlassOpticalPresetSet = Record<GlassOpticalPreset, GlassOpticalParameters>
type GlassOpticalCapabilityPresets = {
balanced: GlassOpticalPresetSet
css: Pick<GlassOpticalPresetSet, 'natural'>
high: GlassOpticalPresetSet
}
const GLASS_OPTICAL_PRESET_MATRIX: Record<GlassAppearance, GlassOpticalCapabilityPresets> = {
clear: {
css: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 28, flow: 42, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 72, flow: 78, reflection: 38, transmission: 66, translation: 56, transparency: 74 },
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 56, translation: 40, transparency: 48 },
},
balanced: {
natural: { deformation: 50, flow: 50, reflection: 35, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 30, flow: 44, reflection: 29, transmission: 72, translation: 72, transparency: 78 },
liquid: { deformation: 70, flow: 76, reflection: 36, transmission: 66, translation: 56, transparency: 74 },
natural: { deformation: 40, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 46 },
glide: { deformation: 24, flow: 35, reflection: 29, transmission: 58, translation: 58, transparency: 56 },
liquid: { deformation: 56, flow: 61, reflection: 36, transmission: 51, translation: 45, transparency: 51 },
},
high: {
natural: { deformation: 50, flow: 50, reflection: 32, transmission: 70, translation: 50, transparency: 70 },
glide: { deformation: 32, flow: 46, reflection: 28, transmission: 74, translation: 74, transparency: 80 },
liquid: { deformation: 74, flow: 80, reflection: 35, transmission: 68, translation: 58, transparency: 76 },
natural: { deformation: 40, flow: 40, reflection: 32, transmission: 53, translation: 40, transparency: 45 },
glide: { deformation: 26, flow: 37, reflection: 28, transmission: 56, translation: 59, transparency: 54 },
liquid: { deformation: 59, flow: 64, reflection: 35, transmission: 50, translation: 46, transparency: 50 },
},
},
tinted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 38, transmission: 68, translation: 50, transparency: 46 },
glide: { deformation: 30, flow: 42, reflection: 34, transmission: 74, translation: 70, transparency: 52 },
liquid: { deformation: 70, flow: 76, reflection: 41, transmission: 64, translation: 54, transparency: 48 },
natural: { deformation: 40, flow: 40, reflection: 38, transmission: 54, translation: 40, transparency: 34 },
},
balanced: {
natural: { deformation: 52, flow: 50, reflection: 38, transmission: 72, translation: 50, transparency: 46 },
glide: { deformation: 32, flow: 44, reflection: 34, transmission: 78, translation: 70, transparency: 52 },
liquid: { deformation: 72, flow: 76, reflection: 39, transmission: 68, translation: 56, transparency: 48 },
natural: { deformation: 42, flow: 40, reflection: 38, transmission: 56, translation: 40, transparency: 32 },
glide: { deformation: 26, flow: 35, reflection: 34, transmission: 61, translation: 56, transparency: 40 },
liquid: { deformation: 58, flow: 61, reflection: 39, transmission: 53, translation: 45, transparency: 36 },
},
high: {
natural: { deformation: 52, flow: 50, reflection: 35, transmission: 76, translation: 50, transparency: 48 },
glide: { deformation: 34, flow: 46, reflection: 32, transmission: 82, translation: 72, transparency: 54 },
liquid: { deformation: 76, flow: 80, reflection: 38, transmission: 72, translation: 58, transparency: 50 },
natural: { deformation: 42, flow: 40, reflection: 35, transmission: 54, translation: 40, transparency: 30 },
glide: { deformation: 27, flow: 37, reflection: 32, transmission: 59, translation: 58, transparency: 38 },
liquid: { deformation: 61, flow: 64, reflection: 38, transmission: 51, translation: 46, transparency: 34 },
},
},
frosted: {
css: {
natural: { deformation: 50, flow: 50, reflection: 31, transmission: 54, translation: 50, transparency: 42 },
glide: { deformation: 34, flow: 42, reflection: 27, transmission: 58, translation: 66, transparency: 46 },
liquid: { deformation: 76, flow: 74, reflection: 34, transmission: 50, translation: 50, transparency: 44 },
natural: { deformation: 40, flow: 40, reflection: 31, transmission: 50, translation: 40, transparency: 31 },
},
balanced: {
natural: { deformation: 58, flow: 52, reflection: 31, transmission: 58, translation: 48, transparency: 42 },
glide: { deformation: 38, flow: 44, reflection: 27, transmission: 62, translation: 68, transparency: 46 },
liquid: { deformation: 78, flow: 76, reflection: 32, transmission: 54, translation: 52, transparency: 44 },
natural: { deformation: 46, flow: 42, reflection: 31, transmission: 52, translation: 38, transparency: 29 },
glide: { deformation: 30, flow: 35, reflection: 27, transmission: 56, translation: 54, transparency: 40 },
liquid: { deformation: 62, flow: 61, reflection: 32, transmission: 49, translation: 42, transparency: 34 },
},
high: {
natural: { deformation: 60, flow: 52, reflection: 29, transmission: 62, translation: 48, transparency: 44 },
glide: { deformation: 40, flow: 46, reflection: 25, transmission: 66, translation: 70, transparency: 48 },
liquid: { deformation: 82, flow: 80, reflection: 31, transmission: 58, translation: 54, transparency: 46 },
natural: { deformation: 48, flow: 42, reflection: 29, transmission: 50, translation: 38, transparency: 27 },
glide: { deformation: 32, flow: 37, reflection: 25, transmission: 54, translation: 56, transparency: 38 },
liquid: { deformation: 66, flow: 64, reflection: 31, transmission: 47, translation: 43, transparency: 32 },
},
},
}
@@ -191,7 +204,10 @@ export function getGlassOpticalPresetParameters(
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
): GlassOpticalParameters {
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
const presets = GLASS_OPTICAL_PRESET_MATRIX[appearance][quality]
const parameters = 'natural' === preset ? presets.natural : (presets as Partial<GlassOpticalPresetSet>)[preset]
return { ...(parameters ?? presets.natural) }
}
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
@@ -199,6 +215,79 @@ export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability)
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
}
/** 生成持久化覆盖使用的稳定组合键;标准档始终归入自然方案。 */
export function getGlassOpticalPresetKey(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
): GlassOpticalPresetKey {
return `${appearance}:${quality}:${quality === 'css' ? 'natural' : preset}`
}
/** 切换组合时优先恢复用户覆盖,没有覆盖才返回预设矩阵副本。 */
export function getGlassOpticalPresetParametersWithOverrides(
appearance: GlassAppearance,
quality: GlassOpticalCapability,
preset: GlassOpticalPreset,
overrides: GlassOpticalPresetOverrides,
) {
const key = getGlassOpticalPresetKey(appearance, quality, preset)
return { ...(overrides[key] ?? getGlassOpticalPresetParameters(appearance, quality, preset)) }
}
const GLASS_RESPONSE_STOPS = [0, 20, 50, 70, 85, 100] as const
const GLASS_BACKGROUND_VISIBILITY: Record<GlassAppearance, readonly number[]> = {
clear: [0.18, 0.3, 0.58, 0.77, 0.9, 0.96],
tinted: [0.08, 0.2, 0.48, 0.7, 0.84, 0.92],
frosted: [0.04, 0.14, 0.35, 0.6, 0.78, 0.88],
}
const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
clear: [1, 0.88, 0.62, 0.42, 0.26, 0.18],
tinted: [1, 0.92, 0.72, 0.52, 0.39, 0.3],
frosted: [1, 0.96, 0.86, 0.68, 0.5, 0.4],
}
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.4, 0.18, 0.1] as const
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
const normalized = normalizeGlassOpticalStrength(value)
const upperIndex = GLASS_RESPONSE_STOPS.findIndex(stop => normalized <= stop)
if (upperIndex <= 0) return anchors[0]
const lowerIndex = upperIndex - 1
const lowerStop = GLASS_RESPONSE_STOPS[lowerIndex]
const upperStop = GLASS_RESPONSE_STOPS[upperIndex]
const linearProgress = (normalized - lowerStop) / (upperStop - lowerStop)
const smoothProgress = linearProgress * linearProgress * (3 - 2 * linearProgress)
return anchors[lowerIndex] + (anchors[upperIndex] - anchors[lowerIndex]) * smoothProgress
}
/**
* 一个通透度输入派生互不混用的材质响应;tone、曝光和透射亮度不在此处计算。
*/
export function getGlassMaterialResponse(appearance: GlassAppearance, value: unknown): GlassMaterialResponse {
const frostDensity = interpolateGlassResponse(value, GLASS_FROST_DENSITY)
return {
backgroundVisibility: interpolateGlassResponse(value, GLASS_BACKGROUND_VISIBILITY[appearance]),
frostBlurScale: 0.4 + frostDensity * 1.2,
frostDetailLevel: 1 - frostDensity,
surfaceDensity: interpolateGlassResponse(value, GLASS_SURFACE_DENSITY[appearance]),
tintDensity: interpolateGlassResponse(value, GLASS_TINT_DENSITY),
}
}
/** 标准档磨砂使用独立的 surface/raised 半径锚点,不借用背景亮度制造厚度。 */
export function getGlassCssFrostBlur(value: unknown) {
return {
raised: interpolateGlassResponse(value, [84, 76, 62, 46, 34, 26]),
surface: interpolateGlassResponse(value, [64, 58, 44, 30, 22, 16]),
}
}
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
if (duration <= 0 || elapsed >= duration) return 1
@@ -258,15 +347,11 @@ export function getGlassOpticalFlowStrengthScale(value: unknown) {
return normalizedRatio ** Math.log2(GLASS_OPTICAL_FLOW_MAX_SCALE)
}
/** 高于默认值的流动强度逐步扩大作用范围,低区间不会意外改变既有空间尺度。 */
/** 流动强度在完整滑杆区间连续控制轨迹范围,低值也能明显收紧空间足迹。 */
export function getGlassOpticalMotionExpansion(value: unknown) {
const normalized = normalizeGlassOpticalStrength(value)
const highRangeProgress = Math.max(
0,
(normalized - GLASS_OPTICAL_STRENGTH_DEFAULT) / (GLASS_OPTICAL_STRENGTH_MAX - GLASS_OPTICAL_STRENGTH_DEFAULT),
)
return highRangeProgress ** 1.55
return (normalized / GLASS_OPTICAL_STRENGTH_MAX) ** 1.4
}
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
@@ -442,13 +527,13 @@ export function getGlassOpticalWakeDirection(
return directionDot < Math.cos((55 * Math.PI) / 180) ? nextDirection : currentDirection
}
/** 活动表面立即可感知,随后与离场表面完成短时单调交叉过渡。 */
/** 活动表面立即接管输入,离场表面只做短时单调淡出。 */
export function getGlassOpticalSurfaceTransitionWeights(elapsed: number, duration: number) {
const progress = Math.min(1, Math.max(0, elapsed / Math.max(1, duration)))
const eased = progress * progress * (3 - 2 * progress)
return {
incoming: 0.35 + eased * 0.65,
incoming: 1,
outgoing: 1 - eased,
}
}
+103 -15
View File
@@ -1,3 +1,5 @@
import { preloadCorsImage } from '@/@core/utils/corsImage'
export interface GlassWallpaperToneProfile {
/** 进入材质曲线前的有限整体曝光,避免壁纸明暗差异直接放大到操作表面。 */
exposure: number
@@ -7,7 +9,24 @@ export interface GlassWallpaperToneProfile {
medianLuminance: number
}
/** 一次图片解码同时给出 WebGL 可读性与壁纸曝光分析结果。 */
export interface GlassWallpaperToneLoadResult {
/** 当前 URL 已按匿名 CORS 模式完成解码,可安全交给 WebGL。 */
corsReady: boolean
/** 与 DOM 背景和 renderer 共用的有限曝光 profile。 */
profile: GlassWallpaperToneProfile
}
/** 交给 renderer 消费的一次性已解码壁纸源。 */
export interface GlassWallpaperDecodedSource {
/** 已完成匿名 CORS 解码的图片。 */
image: HTMLImageElement
/** 与该图片同一次解码得到的曝光 profile。 */
profile: GlassWallpaperToneProfile
}
const ANALYSIS_MAX_EDGE = 64
const DECODED_SOURCE_CACHE_LIMIT = 3
const PROFILE_CACHE_LIMIT = 32
const PROFILE_LOAD_TIMEOUT_MS = 3000
const EXPOSURE_MIN = 0.88
@@ -21,7 +40,8 @@ export const DEFAULT_GLASS_WALLPAPER_TONE_PROFILE: GlassWallpaperToneProfile = {
medianLuminance: MEDIAN_TARGET,
}
const profileCache = new Map<string, Promise<GlassWallpaperToneProfile>>()
const profileCache = new Map<string, Promise<GlassWallpaperToneLoadResult>>()
const decodedSourceCache = new Map<string, GlassWallpaperDecodedSource>()
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -86,7 +106,7 @@ export function analyzeGlassWallpaperTone(image: CanvasImageSource, width: numbe
}
}
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile>) {
function rememberProfile(url: string, profile: Promise<GlassWallpaperToneLoadResult>) {
if (profileCache.size >= PROFILE_CACHE_LIMIT) {
const oldestKey = profileCache.keys().next().value
if (oldestKey) profileCache.delete(oldestKey)
@@ -96,37 +116,105 @@ function rememberProfile(url: string, profile: Promise<GlassWallpaperToneProfile
return profile
}
/** 只保留 current/previous/prepared 三个候选,避免完整解码图片变成长期内存缓存。 */
function rememberDecodedSource(url: string, source: GlassWallpaperDecodedSource) {
decodedSourceCache.delete(url)
decodedSourceCache.set(url, source)
while (decodedSourceCache.size > DECODED_SOURCE_CACHE_LIMIT) {
const oldestKey = decodedSourceCache.keys().next().value
if (oldestKey) decodedSourceCache.delete(oldestKey)
}
}
/**
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile
* 避免亮度分析阻断壁纸本身的 CSS 显示能力
* renderer 取得已完成 tone 分析的图片后立即移出缓存。
* fixed 与 scroll context 后续通过 renderer 自身的 CPU source cache 共享缩放结果
*/
export function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
if (!url || typeof Image === 'undefined') return Promise.resolve({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
export function takeGlassWallpaperDecodedSource(url: string): GlassWallpaperDecodedSource | undefined {
const source = decodedSourceCache.get(url)
if (source) decodedSourceCache.delete(url)
const cached = profileCache.get(url)
if (cached) return cached
return source
}
const profile = new Promise<GlassWallpaperToneProfile>(resolve => {
function loadCorsReadableToneProfile(url: string): Promise<GlassWallpaperToneLoadResult> {
return new Promise(resolve => {
const image = new Image()
let settled = false
const finish = (result: GlassWallpaperToneProfile) => {
const finish = (result: GlassWallpaperToneLoadResult) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
resolve(result)
}
const timeout = window.setTimeout(
() => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE }),
() => finish({ corsReady: false, profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE } }),
PROFILE_LOAD_TIMEOUT_MS,
)
image.crossOrigin = 'anonymous'
image.decoding = 'async'
image.onload = () =>
finish(analyzeGlassWallpaperTone(image, image.naturalWidth || image.width, image.naturalHeight || image.height))
image.onerror = () => finish({ ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE })
image.onload = () => {
const profile = analyzeGlassWallpaperTone(
image,
image.naturalWidth || image.width,
image.naturalHeight || image.height,
)
rememberDecodedSource(url, { image, profile })
finish({
corsReady: true,
profile,
})
}
image.onerror = () =>
finish({
corsReady: false,
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
})
image.src = url
})
}
return rememberProfile(url, profile)
/**
* 以一次匿名图片解码同时完成 WebGL 可读性检查和曝光分析。
* 只有旧的非 CORS 缓存污染读取时才强制重新验证,再进行一次解码。
*/
export function loadGlassWallpaperTone(url: string): Promise<GlassWallpaperToneLoadResult> {
if (!url || typeof Image === 'undefined') {
return Promise.resolve({
corsReady: false,
profile: { ...DEFAULT_GLASS_WALLPAPER_TONE_PROFILE },
})
}
const cached = profileCache.get(url)
if (cached) return cached
const profile = (async () => {
const initial = await loadCorsReadableToneProfile(url)
if (initial.corsReady || !(await preloadCorsImage(url))) return initial
return loadCorsReadableToneProfile(url)
})()
rememberProfile(url, profile)
void profile.then(
result => {
if (!result.corsReady && profileCache.get(url) === profile) profileCache.delete(url)
},
() => {
if (profileCache.get(url) === profile) profileCache.delete(url)
},
)
return profile
}
/**
* 为 DOM 背景层加载可读像素。跨域来源不支持 CORS 时回落中性 profile
* 避免亮度分析阻断壁纸本身的 CSS 显示能力。
*/
export async function loadGlassWallpaperToneProfile(url: string): Promise<GlassWallpaperToneProfile> {
return (await loadGlassWallpaperTone(url)).profile
}