mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
feat(glass): improve optical quality and controls (#585)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
import { commitPreloadedBackgroundRotation, preloadBackgroundRotationImages } from '@/utils/backgroundRotation'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -59,3 +59,31 @@ describe('commitPreloadedBackgroundRotation', () => {
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preloadBackgroundRotationImages', () => {
|
||||
it('does not let an unused optical texture block the visible wallpaper', async () => {
|
||||
const preload = vi.fn(async (url: string) => url === 'display.jpg')
|
||||
|
||||
await expect(
|
||||
preloadBackgroundRotationImages({
|
||||
displayUrl: 'display.jpg',
|
||||
preload,
|
||||
}),
|
||||
).resolves.toBe(true)
|
||||
expect(preload).toHaveBeenCalledOnce()
|
||||
expect(preload).toHaveBeenCalledWith('display.jpg')
|
||||
})
|
||||
|
||||
it('requires both textures when the optical renderer consumes the derived wallpaper', async () => {
|
||||
const preload = vi.fn(async (url: string) => url === 'display.jpg')
|
||||
|
||||
await expect(
|
||||
preloadBackgroundRotationImages({
|
||||
displayUrl: 'display.jpg',
|
||||
opticalUrl: 'optical.jpg',
|
||||
preload,
|
||||
}),
|
||||
).resolves.toBe(false)
|
||||
expect(preload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import {
|
||||
canUseGlassWallpaperTexture,
|
||||
GLASS_OPTICAL_MOTION_MAX_SCALE,
|
||||
GLASS_OPTICAL_REFLECTION_MAX_SCALE,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassCoverScale,
|
||||
getGlassOpticalDecay,
|
||||
getGlassOpticalBufferSize,
|
||||
getGlassOpticalMaxRefractionPixels,
|
||||
getGlassOpticalMotionEnergy,
|
||||
getGlassOpticalMotionExpansion,
|
||||
getGlassOpticalMotionStrengthScale,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalReflectionStrengthScale,
|
||||
getGlassOpticalRenderProfile,
|
||||
getGlassOpticalTransparency,
|
||||
getGlassOpticalSurfaceTransitionWeights,
|
||||
getGlassOpticalWakeDirection,
|
||||
getGlassOpticalWakeSample,
|
||||
getGlassScrollBufferSize,
|
||||
getGlassWallpaperTransitionProgress,
|
||||
normalizeGlassOpticalRect,
|
||||
normalizeGlassOpticalStrength,
|
||||
reconcileGlassOpticalSurfaceSlots,
|
||||
selectGlassOpticalRects,
|
||||
stepGlassOpticalSpring,
|
||||
type GlassOpticalRect,
|
||||
} from '@/utils/glassOptics'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('glass optics geometry', () => {
|
||||
it('caps the renderer buffer independently from device pixel ratio', () => {
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 621, width: 960 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 720, width: 333 })
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false, 'high')).toEqual({ height: 931, width: 1440 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true, 'high')).toEqual({ height: 844, width: 390 })
|
||||
expect(getGlassOpticalBufferSize(3456, 2234, false)).toEqual({ height: 931, width: 1440 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true)).toEqual({ height: 844, width: 390 })
|
||||
expect(getGlassOpticalBufferSize(1920, 1080, false, 'high', 2)).toEqual({ height: 1080, width: 1920 })
|
||||
expect(getGlassOpticalBufferSize(390, 844, true, 'high', 3)).toEqual({ height: 1266, width: 585 })
|
||||
expect(getGlassScrollBufferSize(1440, 4200, 'balanced', 2)).toEqual({ height: 3072, width: 1440 })
|
||||
expect(getGlassScrollBufferSize(1440, 4200, 'high', 2)).toEqual({ height: 4096, width: 1920 })
|
||||
})
|
||||
|
||||
it('matches cover cropping on wide and tall images', () => {
|
||||
@@ -22,34 +43,305 @@ describe('glass optics geometry', () => {
|
||||
expect(getGlassCoverScale(900, 1600, 2400, 1600)).toEqual({ x: 0.375, y: 1 })
|
||||
})
|
||||
|
||||
it('maps user strength sliders to accelerated high-range optical response', () => {
|
||||
expect(normalizeGlassOpticalStrength(Number.NaN)).toBe(50)
|
||||
expect(normalizeGlassOpticalStrength(-12)).toBe(0)
|
||||
expect(normalizeGlassOpticalStrength(44.6)).toBe(45)
|
||||
expect(normalizeGlassOpticalStrength(160)).toBe(100)
|
||||
expect(getGlassOpticalMotionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalMotionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalMotionStrengthScale(80)).toBeGreaterThan(2)
|
||||
expect(getGlassOpticalMotionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_MOTION_MAX_SCALE)
|
||||
expect(getGlassOpticalMotionStrengthScale(75) - getGlassOpticalMotionStrengthScale(50)).toBeGreaterThan(
|
||||
getGlassOpticalMotionStrengthScale(50) - getGlassOpticalMotionStrengthScale(25),
|
||||
)
|
||||
expect(getGlassOpticalMotionExpansion(50)).toBe(0)
|
||||
expect(getGlassOpticalMotionExpansion(80)).toBeGreaterThan(0.4)
|
||||
expect(getGlassOpticalMotionExpansion(100)).toBe(1)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 50)).toBe(9)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 80)).toBe(9)
|
||||
expect(getGlassOpticalMaxRefractionPixels(9, 100)).toBe(9)
|
||||
expect(getGlassOpticalReflectionStrengthScale(0)).toBe(0)
|
||||
expect(getGlassOpticalReflectionStrengthScale(50)).toBe(1)
|
||||
expect(getGlassOpticalReflectionStrengthScale(100)).toBeCloseTo(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
expect(getGlassOpticalTransparency(0)).toBeCloseTo(0.28)
|
||||
expect(getGlassOpticalTransparency(50)).toBeGreaterThan(0.6)
|
||||
expect(getGlassOpticalTransparency(80)).toBeCloseTo(0.86)
|
||||
expect(getGlassOpticalTransparency(100)).toBeCloseTo(0.96)
|
||||
expect(getGlassOpticalTransparency(100) - getGlassOpticalTransparency(80)).toBeLessThan(
|
||||
getGlassOpticalTransparency(80) - getGlassOpticalTransparency(50),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps presets as concrete five-parameter values', () => {
|
||||
const natural = getGlassOpticalPresetParameters('clear', 'balanced', 'natural')
|
||||
const glide = getGlassOpticalPresetParameters('clear', 'balanced', 'glide')
|
||||
const liquid = getGlassOpticalPresetParameters('frosted', 'high', 'liquid')
|
||||
|
||||
expect(natural).toEqual({
|
||||
deformation: 50,
|
||||
flow: 50,
|
||||
reflection: 50,
|
||||
translation: 50,
|
||||
transparency: 50,
|
||||
})
|
||||
expect(glide.translation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.deformation).toBeGreaterThan(glide.deformation)
|
||||
expect(liquid.flow).toBeGreaterThan(glide.flow)
|
||||
expect(getAvailableGlassOpticalPresets('css')).toEqual(['natural'])
|
||||
expect(getAvailableGlassOpticalPresets('balanced')).toEqual(['natural', 'glide', 'liquid'])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it('matches the monotonic CSS ease timeline used by wallpaper crossfades', () => {
|
||||
const samples = [0, 250, 750, 1250, 1500].map(elapsed => getGlassWallpaperTransitionProgress(elapsed, 1500))
|
||||
|
||||
expect(samples[0]).toBe(0)
|
||||
expect(samples.at(-1)).toBe(1)
|
||||
expect(samples[2]).toBeGreaterThan(0.5)
|
||||
expect(samples.every((sample, index) => index === 0 || sample > samples[index - 1])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the selected optical quality on every route', () => {
|
||||
expect(getGlassOpticalRenderProfile('high', '/dashboard')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/recommend?source=tmdb')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/subscribe/movie')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('balanced', '/dashboard')).toEqual({
|
||||
bufferQuality: 'balanced',
|
||||
textureLimit: 2048,
|
||||
contentProtection: false,
|
||||
diffusionSamples: 5,
|
||||
flowField: false,
|
||||
flowHalfLife: 0,
|
||||
maxRefractionPixels: 6,
|
||||
motionDuration: 360,
|
||||
motionHalfLife: 82,
|
||||
pixelRatioCap: 1,
|
||||
pointerImmediateResponse: 0.7,
|
||||
springDamping: 0.9,
|
||||
springFrequency: 24,
|
||||
textureLimit: 3072,
|
||||
textureSource: 'wallpaper',
|
||||
trailCount: 2,
|
||||
})
|
||||
expect(getGlassOpticalRenderProfile('high', '/login')).toEqual({
|
||||
bufferQuality: 'high',
|
||||
textureLimit: 3072,
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
flowHalfLife: 130,
|
||||
maxRefractionPixels: 9,
|
||||
motionDuration: 540,
|
||||
motionHalfLife: 125,
|
||||
pixelRatioCap: 1.5,
|
||||
pointerImmediateResponse: 0.58,
|
||||
springDamping: 0.78,
|
||||
springFrequency: 18,
|
||||
textureLimit: 4096,
|
||||
textureSource: 'auto',
|
||||
trailCount: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('spends high-quality cost on visible detail and content protection', () => {
|
||||
const balanced = getGlassOpticalRenderProfile('balanced', '/dashboard')
|
||||
const high = getGlassOpticalRenderProfile('high', '/dashboard')
|
||||
|
||||
expect(balanced).toMatchObject({
|
||||
contentProtection: false,
|
||||
diffusionSamples: 5,
|
||||
flowField: false,
|
||||
maxRefractionPixels: 6,
|
||||
trailCount: 2,
|
||||
})
|
||||
expect(high).toMatchObject({
|
||||
contentProtection: true,
|
||||
diffusionSamples: 9,
|
||||
flowField: true,
|
||||
maxRefractionPixels: 9,
|
||||
trailCount: 4,
|
||||
})
|
||||
expect(high.motionDuration).toBeGreaterThan(balanced.motionDuration)
|
||||
expect(high.pixelRatioCap).toBeGreaterThan(balanced.pixelRatioCap)
|
||||
})
|
||||
|
||||
it('uses refresh-rate independent decay and reaches a deterministic static state', () => {
|
||||
expect(getGlassOpticalDecay(100, 100)).toBeCloseTo(0.5)
|
||||
expect(getGlassOpticalDecay(100, 50) ** 2).toBeCloseTo(getGlassOpticalDecay(100, 100))
|
||||
expect(getGlassOpticalMotionEnergy(0, 420, 90)).toBe(1)
|
||||
expect(getGlassOpticalMotionEnergy(90, 420, 90)).toBeCloseTo(0.5)
|
||||
expect(getGlassOpticalMotionEnergy(400, 420, 90)).toBeLessThan(0.005)
|
||||
expect(getGlassOpticalMotionEnergy(410, 420, 90)).toBeLessThan(getGlassOpticalMotionEnergy(400, 420, 90))
|
||||
expect(getGlassOpticalMotionEnergy(420, 420, 90)).toBe(0)
|
||||
|
||||
const samples = Array.from({ length: 29 }, (_, index) => getGlassOpticalMotionEnergy(index * 15, 420, 90))
|
||||
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps one crest and one trough in the event-relative liquid wake', () => {
|
||||
const samples = Array.from({ length: 161 }, (_, index) => getGlassOpticalWakeSample(-4 + index * 0.05))
|
||||
const extrema = samples
|
||||
.slice(1, -1)
|
||||
.filter(
|
||||
(sample, index) =>
|
||||
(sample > samples[index] && sample > samples[index + 2]) ||
|
||||
(sample < samples[index] && sample < samples[index + 2]),
|
||||
)
|
||||
|
||||
expect(extrema).toHaveLength(2)
|
||||
expect(Math.min(...samples)).toBeLessThan(0)
|
||||
expect(Math.max(...samples)).toBeGreaterThan(0)
|
||||
expect(getGlassOpticalWakeSample(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('locks wake direction until a deliberate turn starts a new impulse', () => {
|
||||
expect(
|
||||
getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: Math.cos(Math.PI / 8), y: Math.sin(Math.PI / 8) }, 0.04, false),
|
||||
).toEqual({ x: 1, y: 0 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.04, false)).toEqual({ x: 0, y: 1 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 1 }, 0.002, false)).toEqual({ x: 1, y: 0 })
|
||||
expect(getGlassOpticalWakeDirection({ x: 1, y: 0 }, { x: 0, y: 2 }, 0.04, true)).toEqual({ x: 0, y: 1 })
|
||||
})
|
||||
|
||||
it('uses a frame-rate independent spring with at most one visible overshoot', () => {
|
||||
const profile = getGlassOpticalRenderProfile('high', '/dashboard')
|
||||
const simulate = (deltaMs: number) => {
|
||||
let state = { position: 0, velocity: 0 }
|
||||
const samples: number[] = []
|
||||
|
||||
for (let elapsed = 0; elapsed < profile.motionDuration; elapsed += deltaMs) {
|
||||
state = stepGlassOpticalSpring(state, 1, deltaMs, profile.springFrequency, profile.springDamping)
|
||||
samples.push(state.position)
|
||||
}
|
||||
|
||||
return { samples, state }
|
||||
}
|
||||
const sixtyHertz = simulate(1000 / 60)
|
||||
const oneTwentyHertz = simulate(1000 / 120)
|
||||
const visibleCrossings = sixtyHertz.samples.slice(1).filter((sample, index) => {
|
||||
const previousOffset = sixtyHertz.samples[index] - 1
|
||||
const currentOffset = sample - 1
|
||||
|
||||
return previousOffset * currentOffset < 0 && Math.max(Math.abs(previousOffset), Math.abs(currentOffset)) > 0.002
|
||||
})
|
||||
|
||||
expect(visibleCrossings.length).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...sixtyHertz.samples)).toBeLessThan(1.04)
|
||||
expect(sixtyHertz.state.position).toBeCloseTo(1, 2)
|
||||
expect(sixtyHertz.state.position).toBeCloseTo(oneTwentyHertz.state.position, 3)
|
||||
})
|
||||
|
||||
it('reconciles capped surface slots without reshuffling stable cards', () => {
|
||||
const candidates = Array.from({ length: 10 }, (_, index) => ({
|
||||
key: `card-${index}`,
|
||||
rect: {
|
||||
height: 80,
|
||||
radii: [12, 12, 12, 12] as [number, number, number, number],
|
||||
rank: index + 1,
|
||||
width: 90,
|
||||
x: index * 100,
|
||||
y: 20,
|
||||
},
|
||||
}))
|
||||
const initial = reconcileGlassOpticalSurfaceSlots([], candidates, 8)
|
||||
const onCardA = reconcileGlassOpticalSurfaceSlots(initial, candidates, 8, 'card-8')
|
||||
const throughGap = reconcileGlassOpticalSurfaceSlots(onCardA, candidates, 8, 'card-8')
|
||||
const onCardB = reconcileGlassOpticalSurfaceSlots(throughGap, candidates, 8, 'card-9', 'card-8')
|
||||
|
||||
expect(initial.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-6',
|
||||
'card-7',
|
||||
])
|
||||
expect(onCardA.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-6',
|
||||
'card-8',
|
||||
])
|
||||
expect(throughGap.map(slot => slot.key)).toEqual(onCardA.map(slot => slot.key))
|
||||
expect(onCardB.map(slot => slot.key)).toEqual([
|
||||
'card-0',
|
||||
'card-1',
|
||||
'card-2',
|
||||
'card-3',
|
||||
'card-4',
|
||||
'card-5',
|
||||
'card-8',
|
||||
'card-9',
|
||||
])
|
||||
expect(onCardB.at(-2)?.role).toBe('outgoing')
|
||||
expect(onCardB.at(-1)?.role).toBe('active')
|
||||
})
|
||||
|
||||
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(96, 96)).toEqual({ incoming: 1, outgoing: 0 })
|
||||
})
|
||||
|
||||
it('only uploads browser-readable login wallpapers to WebGL', () => {
|
||||
const documentUrl = 'https://moviepilot.example/login'
|
||||
|
||||
@@ -63,10 +355,10 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('keeps high-value outer surfaces and removes nested repeats', () => {
|
||||
const candidates: GlassOpticalRect[] = [
|
||||
{ height: 900, radius: 0, rank: 2, width: 260, x: 0, y: 0 },
|
||||
{ height: 300, radius: 16, rank: 4, width: 500, x: 300, y: 100 },
|
||||
{ height: 100, radius: 12, rank: 5, width: 200, x: 320, y: 120 },
|
||||
{ height: 120, radius: 12, rank: 1, width: 400, x: 500, y: 700 },
|
||||
{ height: 900, radii: [0, 0, 0, 0], rank: 2, width: 260, x: 0, y: 0 },
|
||||
{ height: 300, radii: [16, 16, 16, 16], rank: 4, width: 500, x: 300, y: 100 },
|
||||
{ height: 100, radii: [12, 12, 12, 12], rank: 5, width: 200, x: 320, y: 120 },
|
||||
{ height: 120, radii: [12, 12, 12, 12], rank: 1, width: 400, x: 500, y: 700 },
|
||||
]
|
||||
|
||||
const selected = selectGlassOpticalRects(candidates, 1440, 900, false)
|
||||
@@ -77,20 +369,20 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('keeps original geometry while using the viewport intersection for visibility', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }],
|
||||
[{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }],
|
||||
320,
|
||||
240,
|
||||
false,
|
||||
)
|
||||
|
||||
expect(selected).toEqual([{ height: 160, radius: 20, rank: 1, width: 180, x: -30, y: -40 }])
|
||||
expect(selected).toEqual([{ height: 160, radii: [20, 20, 20, 20], rank: 1, width: 180, x: -30, y: -40 }])
|
||||
})
|
||||
|
||||
it('budgets partially visible surfaces by their visible pixels', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[
|
||||
{ height: 1000, radius: 20, rank: 1, width: 1000, x: -950, y: -900 },
|
||||
{ height: 120, radius: 16, rank: 2, width: 200, x: 80, y: 80 },
|
||||
{ height: 1000, radii: [20, 20, 20, 20], rank: 1, width: 1000, x: -950, y: -900 },
|
||||
{ height: 120, radii: [16, 16, 16, 16], rank: 2, width: 200, x: 80, y: 80 },
|
||||
],
|
||||
320,
|
||||
240,
|
||||
@@ -102,11 +394,58 @@ describe('glass optics geometry', () => {
|
||||
|
||||
it('converts DOM top-origin rectangles while preserving pixel radius', () => {
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radius: 20, rank: 1, width: 200, x: 100, y: 50 }, 1000, 500),
|
||||
).toEqual({ radius: 20, rect: [0.1, 0.7, 0.2, 0.2] })
|
||||
normalizeGlassOpticalRect(
|
||||
{ height: 100, radii: [20, 18, 16, 14], rank: 1, width: 200, x: 100, y: 50 },
|
||||
1000,
|
||||
500,
|
||||
),
|
||||
).toEqual({ radii: [20, 18, 16, 14], rect: [0.1, 0.7, 0.2, 0.2] })
|
||||
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radius: 80, rank: 1, width: 200, x: -20, y: 50 }, 1000, 500),
|
||||
).toEqual({ radius: 50, rect: [-0.02, 0.7, 0.2, 0.2] })
|
||||
normalizeGlassOpticalRect(
|
||||
{ height: 100, radii: [80, 70, 60, 40], rank: 1, width: 200, x: -20, y: 50 },
|
||||
1000,
|
||||
500,
|
||||
),
|
||||
).toEqual({
|
||||
radii: [80 * (10 / 13), 70 * (10 / 13), 60 * (10 / 13), 40 * (10 / 13)],
|
||||
rect: [-0.02, 0.7, 0.2, 0.2],
|
||||
})
|
||||
|
||||
expect(
|
||||
normalizeGlassOpticalRect({ height: 100, radii: [80, 30, 0, 0], rank: 1, width: 100, x: 0, y: 0 }, 100, 100)
|
||||
.radii,
|
||||
).toEqual([80 * (10 / 11), 30 * (10 / 11), 0, 0])
|
||||
})
|
||||
|
||||
it('keeps visible surfaces without an area-based hard cutoff', () => {
|
||||
const selected = selectGlassOpticalRects(
|
||||
[
|
||||
{ height: 400, radii: [20, 20, 20, 20], rank: 1, width: 700, x: 0, y: 0 },
|
||||
{ height: 400, radii: [20, 20, 20, 20], rank: 2, width: 700, x: 0, y: 400 },
|
||||
],
|
||||
700,
|
||||
800,
|
||||
false,
|
||||
)
|
||||
|
||||
expect(selected).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('prioritizes the surface under the current interaction when the count is capped', () => {
|
||||
const candidates = Array.from({ length: 9 }, (_, index): GlassOpticalRect => ({
|
||||
height: 80,
|
||||
radii: [12, 12, 12, 12],
|
||||
rank: index + 1,
|
||||
width: 100,
|
||||
x: index * 110,
|
||||
y: 20,
|
||||
}))
|
||||
|
||||
const selected = selectGlassOpticalRects(candidates, 1000, 200, false, { x: 930, y: 60 })
|
||||
|
||||
expect(selected).toHaveLength(8)
|
||||
expect(selected).toContain(candidates[8])
|
||||
expect(selected).not.toContain(candidates[7])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,15 @@ interface PreloadedBackgroundRotationOptions {
|
||||
preload: () => Promise<boolean>
|
||||
}
|
||||
|
||||
interface BackgroundRotationImagePreloadOptions {
|
||||
/** 外层背景实际显示的壁纸地址。 */
|
||||
displayUrl: string
|
||||
/** 实时光学 renderer 确实会消费时才提供的同源纹理地址。 */
|
||||
opticalUrl?: string
|
||||
/** 执行单张图片预加载并返回可用状态。 */
|
||||
preload: (url: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* 将壁纸预加载与最终提交分离,确保异步加载期间失效的轮换请求不会改变可见背景。
|
||||
*/
|
||||
@@ -18,3 +27,16 @@ export async function commitPreloadedBackgroundRotation(options: PreloadedBackgr
|
||||
options.commit()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载一次轮换真正依赖的壁纸;未启用光学采样时不让派生纹理阻断外层背景切换。
|
||||
*/
|
||||
export async function preloadBackgroundRotationImages(options: BackgroundRotationImagePreloadOptions) {
|
||||
const urls =
|
||||
options.opticalUrl && options.opticalUrl !== options.displayUrl
|
||||
? [options.displayUrl, options.opticalUrl]
|
||||
: [options.displayUrl]
|
||||
const results = await Promise.all(urls.map(url => options.preload(url)))
|
||||
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
+479
-16
@@ -1,13 +1,45 @@
|
||||
export const GLASS_OPTICAL_MAX_SURFACES_DESKTOP = 8
|
||||
export const GLASS_OPTICAL_MAX_SURFACES_MOBILE = 5
|
||||
export const GLASS_OPTICAL_MOTION_MAX_SCALE = 3.2
|
||||
export const GLASS_OPTICAL_DEFORMATION_MAX_SCALE = 1.55
|
||||
export const GLASS_OPTICAL_FLOW_MAX_SCALE = 1.45
|
||||
export const GLASS_OPTICAL_REFLECTION_MAX_SCALE = 1.8
|
||||
export const GLASS_OPTICAL_TRANSLATION_MAX_SCALE = 1.7
|
||||
export const GLASS_OPTICAL_STRENGTH_DEFAULT = 50
|
||||
export const GLASS_OPTICAL_STRENGTH_MAX = 100
|
||||
export const GLASS_OPTICAL_STRENGTH_MIN = 0
|
||||
|
||||
export type GlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
export type GlassOpticalCapability = 'balanced' | 'css' | 'high'
|
||||
export type GlassOpticalPreset = 'glide' | 'liquid' | 'natural'
|
||||
export type GlassOpticalQuality = 'balanced' | 'high'
|
||||
export type GlassCornerRadii = [number, number, number, number]
|
||||
|
||||
export interface GlassOpticalParameters {
|
||||
/** 局部非均匀折射与内容弯曲强度。 */
|
||||
deformation: number
|
||||
/** 轨迹范围、尾波、惯性与收敛强度。 */
|
||||
flow: number
|
||||
/** 方向高光、迎光棱镜与背光吸收强度。 */
|
||||
reflection: number
|
||||
/** 共享壁纸采样在表面内的统一坐标平移强度。 */
|
||||
translation: number
|
||||
/** 壁纸可见度与材质遮罩强度。 */
|
||||
transparency: number
|
||||
}
|
||||
|
||||
export interface GlassInteractionPoint {
|
||||
/** 指针或触点相对视口的横坐标。 */
|
||||
x: number
|
||||
/** 指针或触点相对视口的纵坐标。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalRect {
|
||||
/** 元素的实际高度,元素可部分位于视口外。 */
|
||||
height: number
|
||||
/** 元素圆角的 CSS 像素值。 */
|
||||
radius: number
|
||||
/** 按左上、右上、右下、左下排列的 CSS 圆角像素值。 */
|
||||
radii: GlassCornerRadii
|
||||
/** 表面的场景优先级,数值越小越优先。 */
|
||||
rank: number
|
||||
/** 元素的实际宽度,元素可部分位于视口外。 */
|
||||
@@ -23,13 +55,240 @@ export interface GlassOpticalBufferSize {
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalPoint {
|
||||
/** 归一化或视口坐标系中的横向分量。 */
|
||||
x: number
|
||||
/** 归一化或视口坐标系中的纵向分量。 */
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalSpringState {
|
||||
/** 当前跟随位置。 */
|
||||
position: number
|
||||
/** 当前每秒位移速度。 */
|
||||
velocity: number
|
||||
}
|
||||
|
||||
export interface GlassOpticalSurfaceCandidate<TKey> {
|
||||
/** renderer 生命周期内稳定的表面身份。 */
|
||||
key: TKey
|
||||
/** 表面的当前视口几何。 */
|
||||
rect: GlassOpticalRect
|
||||
}
|
||||
|
||||
export interface GlassOpticalSurfaceSlot<TKey> extends GlassOpticalSurfaceCandidate<TKey> {
|
||||
/** 槽位在本次交互中的职责。 */
|
||||
role: 'active' | 'outgoing' | 'stable'
|
||||
}
|
||||
|
||||
export interface GlassOpticalRenderProfile {
|
||||
/** 光学层内部缓冲使用的质量档位。 */
|
||||
bufferQuality: GlassOpticalQuality
|
||||
/** 是否使用额外壁纸采样保护人物和文字等高梯度内容。 */
|
||||
contentProtection: boolean
|
||||
/** 磨砂扩散使用的纹理采样数。 */
|
||||
diffusionSamples: 5 | 9
|
||||
/** 是否使用带时序记忆的液态位移场。 */
|
||||
flowField: boolean
|
||||
/** 时序位移场能量衰减到一半所需的时间。 */
|
||||
flowHalfLife: number
|
||||
/** 动态折射在视口像素空间中的软上限。 */
|
||||
maxRefractionPixels: number
|
||||
/** 输入停止后液态形态收敛到静态所需的时间。 */
|
||||
motionDuration: number
|
||||
/** 主液态反馈能量衰减到一半所需的时间。 */
|
||||
motionHalfLife: number
|
||||
/** 高质量缓冲允许使用的设备像素比上限。 */
|
||||
pixelRatioCap: number
|
||||
/** 新输入立即作用于镜片位置的比例。 */
|
||||
pointerImmediateResponse: number
|
||||
/** 镜片跟随弹簧的阻尼比。 */
|
||||
springDamping: number
|
||||
/** 镜片跟随弹簧的角频率,单位弧度每秒。 */
|
||||
springFrequency: number
|
||||
/** 活动壁纸进入 GPU 前的最长边限制。 */
|
||||
textureLimit: number
|
||||
/** 登录页优先使用可读纹理,跨域外链自动退回程序化高光。 */
|
||||
textureSource: 'auto' | 'procedural' | 'wallpaper'
|
||||
/** 参与液态方向计算的最近输入采样数量。 */
|
||||
trailCount: number
|
||||
}
|
||||
|
||||
/** 将用户滑杆输入收敛到 renderer 支持的整数范围,非法存量值回落到默认视觉。 */
|
||||
export function normalizeGlassOpticalStrength(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
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>>
|
||||
> = {
|
||||
clear: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 28, flow: 42, reflection: 42, translation: 72, transparency: 58 },
|
||||
liquid: { deformation: 72, flow: 78, reflection: 54, translation: 56, transparency: 52 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 50, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 30, flow: 44, reflection: 42, translation: 72, transparency: 58 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 52, translation: 56, transparency: 52 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 46, translation: 50, transparency: 50 },
|
||||
glide: { deformation: 32, flow: 46, reflection: 40, translation: 74, transparency: 60 },
|
||||
liquid: { deformation: 74, flow: 80, reflection: 50, translation: 58, transparency: 54 },
|
||||
},
|
||||
},
|
||||
tinted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 54, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 30, flow: 42, reflection: 48, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 70, flow: 76, reflection: 58, translation: 54, transparency: 48 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 54, translation: 50, transparency: 46 },
|
||||
glide: { deformation: 32, flow: 44, reflection: 48, translation: 70, transparency: 52 },
|
||||
liquid: { deformation: 72, flow: 76, reflection: 56, translation: 56, transparency: 48 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 52, flow: 50, reflection: 50, translation: 50, transparency: 48 },
|
||||
glide: { deformation: 34, flow: 46, reflection: 46, translation: 72, transparency: 54 },
|
||||
liquid: { deformation: 76, flow: 80, reflection: 54, translation: 58, transparency: 50 },
|
||||
},
|
||||
},
|
||||
frosted: {
|
||||
css: {
|
||||
natural: { deformation: 50, flow: 50, reflection: 44, translation: 50, transparency: 42 },
|
||||
glide: { deformation: 34, flow: 42, reflection: 38, translation: 66, transparency: 46 },
|
||||
liquid: { deformation: 76, flow: 74, reflection: 48, translation: 50, transparency: 44 },
|
||||
},
|
||||
balanced: {
|
||||
natural: { deformation: 58, flow: 52, reflection: 44, translation: 48, transparency: 42 },
|
||||
glide: { deformation: 38, flow: 44, reflection: 38, translation: 68, transparency: 46 },
|
||||
liquid: { deformation: 78, flow: 76, reflection: 46, translation: 52, transparency: 44 },
|
||||
},
|
||||
high: {
|
||||
natural: { deformation: 60, flow: 52, reflection: 42, translation: 48, transparency: 44 },
|
||||
glide: { deformation: 40, flow: 46, reflection: 36, translation: 70, transparency: 48 },
|
||||
liquid: { deformation: 82, flow: 80, reflection: 44, translation: 54, transparency: 46 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 返回材质、质量与预置共同确定的五个具体参数,调用方可以安全修改返回值。 */
|
||||
export function getGlassOpticalPresetParameters(
|
||||
appearance: GlassAppearance,
|
||||
quality: GlassOpticalCapability,
|
||||
preset: GlassOpticalPreset,
|
||||
): GlassOpticalParameters {
|
||||
return { ...GLASS_OPTICAL_PRESET_MATRIX[appearance][quality][preset] }
|
||||
}
|
||||
|
||||
/** 标准档只保留自然基线;实时档同时开放滑移与液态方案。 */
|
||||
export function getAvailableGlassOpticalPresets(quality: GlassOpticalCapability): GlassOpticalPreset[] {
|
||||
return quality === 'css' ? ['natural'] : ['natural', 'glide', 'liquid']
|
||||
}
|
||||
|
||||
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
if (elapsed <= 0) return 0
|
||||
|
||||
const target = elapsed / duration
|
||||
const sample = (time: number, start: number, end: number) => {
|
||||
const inverse = 1 - time
|
||||
|
||||
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
|
||||
}
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
let parameter = target
|
||||
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
parameter = (lower + upper) * 0.5
|
||||
if (sample(parameter, 0.25, 0.25) < target) lower = parameter
|
||||
else upper = parameter
|
||||
}
|
||||
|
||||
return sample(parameter, 0.1, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 流动强度使用感知曲线:中点保持既有视觉,高区间同时释放更大的形变幅度与空间范围。
|
||||
* 最大值仍受质量档和内容保护共同约束,避免高梯度海报出现无界拉伸。
|
||||
*/
|
||||
export function getGlassOpticalMotionStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_MOTION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 采样平移保持中点等于既有即时位移,并在高区间受控增长。 */
|
||||
export function getGlassOpticalTranslationStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_TRANSLATION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 非均匀形变独立缩放局部折射,最终像素位移仍受质量档软上限限制。 */
|
||||
export function getGlassOpticalDeformationStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_DEFORMATION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 流动维度只延展轨迹、尾波与惯性,中点保持当前时序手感。 */
|
||||
export function getGlassOpticalFlowStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 最大几何形变只由质量档约束;流动滑杆改变覆盖与连续性,不继续拉伸背景内容。 */
|
||||
export function getGlassOpticalMaxRefractionPixels(basePixels: number, _value: unknown) {
|
||||
void _value
|
||||
|
||||
return basePixels
|
||||
}
|
||||
|
||||
/** 反射强度使用独立感知曲线,只控制光学亮度,不放大背景位移。 */
|
||||
export function getGlassOpticalReflectionStrengthScale(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
const normalizedRatio = normalized / GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
|
||||
return normalizedRatio ** Math.log2(GLASS_OPTICAL_REFLECTION_MAX_SCALE)
|
||||
}
|
||||
|
||||
/** 通透度独立控制真实背景与可读性遮罩的占比,高区间继续增强但逐步收敛。 */
|
||||
export function getGlassOpticalTransparency(value: unknown) {
|
||||
const normalized = normalizeGlassOpticalStrength(value)
|
||||
if (normalized <= 80) {
|
||||
const progress = normalized / 80
|
||||
|
||||
return 0.28 + 0.58 * progress ** 1.25
|
||||
}
|
||||
|
||||
const highRangeProgress = (normalized - 80) / 20
|
||||
|
||||
return 0.86 + 0.1 * highRangeProgress ** 1.5
|
||||
}
|
||||
|
||||
/** 质量决定合成缓冲与纹理上限;路由只切换纹理来源,不改变质量档位。 */
|
||||
@@ -37,13 +296,174 @@ export function getGlassOpticalRenderProfile(
|
||||
quality: GlassOpticalQuality,
|
||||
routeKey: string,
|
||||
): GlassOpticalRenderProfile {
|
||||
const highQuality = quality === 'high'
|
||||
|
||||
return {
|
||||
bufferQuality: quality,
|
||||
textureLimit: quality === 'high' ? 3072 : 2048,
|
||||
contentProtection: highQuality,
|
||||
diffusionSamples: highQuality ? 9 : 5,
|
||||
flowField: highQuality,
|
||||
flowHalfLife: highQuality ? 130 : 0,
|
||||
maxRefractionPixels: highQuality ? 9 : 6,
|
||||
motionDuration: highQuality ? 540 : 360,
|
||||
motionHalfLife: highQuality ? 125 : 82,
|
||||
pixelRatioCap: highQuality ? 1.5 : 1,
|
||||
pointerImmediateResponse: highQuality ? 0.58 : 0.7,
|
||||
springDamping: highQuality ? 0.78 : 0.9,
|
||||
springFrequency: highQuality ? 18 : 24,
|
||||
textureLimit: highQuality ? 4096 : 3072,
|
||||
textureSource: routeKey.startsWith('/login') ? 'auto' : 'wallpaper',
|
||||
trailCount: highQuality ? 4 : 2,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将时间常数转换为与刷新率无关的指数衰减。 */
|
||||
export function getGlassOpticalDecay(halfLife: number, delta: number) {
|
||||
if (halfLife <= 0) return 0
|
||||
if (delta <= 0) return 1
|
||||
|
||||
return 2 ** (-delta / halfLife)
|
||||
}
|
||||
|
||||
/** 输入停止后按时间收敛,尾段平滑归零以恢复事件驱动静止状态。 */
|
||||
export function getGlassOpticalMotionEnergy(elapsed: number, duration: number, halfLife: number) {
|
||||
if (elapsed >= duration) return 0
|
||||
|
||||
const safeElapsed = Math.max(0, elapsed)
|
||||
const tailDuration = Math.max(1, duration * 0.25)
|
||||
const tailProgress = Math.min(1, Math.max(0, (duration - safeElapsed) / tailDuration))
|
||||
const tailTaper = tailProgress * tailProgress * (3 - 2 * tailProgress)
|
||||
|
||||
return getGlassOpticalDecay(halfLife, safeElapsed) * tailTaper
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析阻尼弹簧的精确时间步,避免刷新率变化改变跟随手感。
|
||||
* 欠阻尼参数只允许一次可见回摆,生命周期结束后由 renderer 归零。
|
||||
*/
|
||||
export function stepGlassOpticalSpring(
|
||||
state: GlassOpticalSpringState,
|
||||
target: number,
|
||||
deltaMs: number,
|
||||
frequency: number,
|
||||
damping: number,
|
||||
): GlassOpticalSpringState {
|
||||
const deltaSeconds = Math.min(0.064, Math.max(0, deltaMs / 1000))
|
||||
const safeFrequency = Math.max(0.001, frequency)
|
||||
const safeDamping = Math.max(0, damping)
|
||||
const offset = state.position - target
|
||||
if (deltaSeconds <= 0) return state
|
||||
|
||||
if (safeDamping >= 1) {
|
||||
const decay = Math.exp(-safeFrequency * deltaSeconds)
|
||||
const coefficient = state.velocity + safeFrequency * offset
|
||||
|
||||
return {
|
||||
position: target + (offset + coefficient * deltaSeconds) * decay,
|
||||
velocity: (state.velocity - safeFrequency * coefficient * deltaSeconds) * decay,
|
||||
}
|
||||
}
|
||||
|
||||
const dampedFrequency = safeFrequency * Math.sqrt(1 - safeDamping * safeDamping)
|
||||
const decay = Math.exp(-safeDamping * safeFrequency * deltaSeconds)
|
||||
const angle = dampedFrequency * deltaSeconds
|
||||
const cosine = Math.cos(angle)
|
||||
const sine = Math.sin(angle)
|
||||
const sineCoefficient = (state.velocity + safeDamping * safeFrequency * offset) / dampedFrequency
|
||||
const oscillation = offset * cosine + sineCoefficient * sine
|
||||
|
||||
return {
|
||||
position: target + decay * oscillation,
|
||||
velocity:
|
||||
decay *
|
||||
(-safeDamping * safeFrequency * oscillation -
|
||||
offset * dampedFrequency * sine +
|
||||
sineCoefficient * dampedFrequency * cosine),
|
||||
}
|
||||
}
|
||||
|
||||
/** 单个双相尾波只有一个波峰和一个波谷,不形成连续周期波列。 */
|
||||
export function getGlassOpticalWakeSample(position: number) {
|
||||
return position * Math.exp(-0.5 * position * position)
|
||||
}
|
||||
|
||||
/** 低速噪声和小角度移动沿用既有方向,显著转向才开始新的尾波。 */
|
||||
export function getGlassOpticalWakeDirection(
|
||||
current: GlassOpticalPoint,
|
||||
next: GlassOpticalPoint,
|
||||
speed: number,
|
||||
restart: boolean,
|
||||
): GlassOpticalPoint {
|
||||
const normalize = (point: GlassOpticalPoint) => {
|
||||
const length = Math.hypot(point.x, point.y)
|
||||
|
||||
return length > 0.0001 ? { x: point.x / length, y: point.y / length } : { x: 0, y: -1 }
|
||||
}
|
||||
const currentDirection = normalize(current)
|
||||
const nextDirection = normalize(next)
|
||||
if (restart || Math.hypot(current.x, current.y) <= 0.0001) return nextDirection
|
||||
if (speed < 0.006) return currentDirection
|
||||
|
||||
const directionDot = currentDirection.x * nextDirection.x + currentDirection.y * nextDirection.y
|
||||
|
||||
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,
|
||||
outgoing: 1 - eased,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用仍可见的稳定槽位,并为当前与离场表面保留确定位置。
|
||||
* 候选顺序只用于补充空位,指针移动不会重排已经占用的稳定槽位。
|
||||
*/
|
||||
export function reconcileGlassOpticalSurfaceSlots<TKey>(
|
||||
previous: GlassOpticalSurfaceSlot<TKey>[],
|
||||
candidates: GlassOpticalSurfaceCandidate<TKey>[],
|
||||
maxCount: number,
|
||||
activeKey?: TKey,
|
||||
outgoingKey?: TKey,
|
||||
): GlassOpticalSurfaceSlot<TKey>[] {
|
||||
if (maxCount <= 0) return []
|
||||
|
||||
const candidateByKey = new Map(candidates.map(candidate => [candidate.key, candidate]))
|
||||
const reserved: GlassOpticalSurfaceSlot<TKey>[] = []
|
||||
if (outgoingKey !== undefined && outgoingKey !== activeKey) {
|
||||
const outgoing = candidateByKey.get(outgoingKey)
|
||||
if (outgoing) reserved.push({ ...outgoing, role: 'outgoing' })
|
||||
}
|
||||
if (activeKey !== undefined) {
|
||||
const active = candidateByKey.get(activeKey)
|
||||
if (active) reserved.push({ ...active, role: 'active' })
|
||||
}
|
||||
|
||||
const reservedKeys = new Set(reserved.map(slot => slot.key))
|
||||
const stableCount = Math.max(0, maxCount - reserved.length)
|
||||
const stable: GlassOpticalSurfaceSlot<TKey>[] = []
|
||||
const stableKeys = new Set<TKey>()
|
||||
const appendStable = (key: TKey) => {
|
||||
if (stable.length >= stableCount || reservedKeys.has(key) || stableKeys.has(key)) return
|
||||
|
||||
const candidate = candidateByKey.get(key)
|
||||
if (!candidate) return
|
||||
|
||||
stable.push({ ...candidate, role: 'stable' })
|
||||
stableKeys.add(key)
|
||||
}
|
||||
|
||||
for (const slot of previous) appendStable(slot.key)
|
||||
for (const candidate of candidates) appendStable(candidate.key)
|
||||
|
||||
return [...stable, ...reserved].slice(0, maxCount)
|
||||
}
|
||||
|
||||
/** 只将同源及本地对象交给 WebGL,避免跨域纹理失败污染登录页控制台。 */
|
||||
export function canUseGlassWallpaperTexture(url: string, documentUrl: string): boolean {
|
||||
if (!url || !documentUrl) return false
|
||||
@@ -58,18 +478,20 @@ export function canUseGlassWallpaperTexture(url: string, documentUrl: string): b
|
||||
}
|
||||
}
|
||||
|
||||
/** 按固定像素预算计算内部缓冲尺寸,避免高 DPI 屏幕线性放大 GPU 成本。 */
|
||||
/** 按质量档位的像素预算计算内部缓冲,高质量只使用受控的设备像素比增益。 */
|
||||
export function getGlassOpticalBufferSize(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
mobile: boolean,
|
||||
quality: GlassOpticalQuality = 'balanced',
|
||||
devicePixelRatio = 1,
|
||||
): GlassOpticalBufferSize {
|
||||
const safeWidth = Math.max(1, viewportWidth)
|
||||
const safeHeight = Math.max(1, viewportHeight)
|
||||
const highQuality = quality === 'high'
|
||||
const maxWidth = mobile ? (highQuality ? 960 : 720) : highQuality ? 1440 : 960
|
||||
const maxHeight = highQuality ? 960 : 720
|
||||
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
|
||||
const safeWidth = Math.max(1, viewportWidth) * pixelRatio
|
||||
const safeHeight = Math.max(1, viewportHeight) * pixelRatio
|
||||
const maxWidth = mobile ? (highQuality ? 1280 : 960) : highQuality ? 1920 : 1440
|
||||
const maxHeight = mobile ? (highQuality ? 1440 : 960) : highQuality ? 1200 : 960
|
||||
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight)
|
||||
|
||||
return {
|
||||
@@ -78,6 +500,24 @@ export function getGlassOpticalBufferSize(
|
||||
}
|
||||
}
|
||||
|
||||
/** 文档空间画布独立限制横纵分辨率,避免长页面按纵横比连带降低横向清晰度。 */
|
||||
export function getGlassScrollBufferSize(
|
||||
presentationWidth: number,
|
||||
presentationHeight: number,
|
||||
quality: GlassOpticalQuality,
|
||||
devicePixelRatio = 1,
|
||||
): GlassOpticalBufferSize {
|
||||
const highQuality = quality === 'high'
|
||||
const pixelRatio = highQuality ? Math.min(Math.max(1, devicePixelRatio), 1.5) : 1
|
||||
const maxWidth = highQuality ? 1920 : 1440
|
||||
const maxHeight = highQuality ? 4096 : 3072
|
||||
|
||||
return {
|
||||
height: Math.max(1, Math.round(Math.min(presentationHeight * pixelRatio, maxHeight))),
|
||||
width: Math.max(1, Math.round(Math.min(presentationWidth * pixelRatio, maxWidth))),
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算与 CSS `background-size: cover` 一致的纹理缩放参数。 */
|
||||
export function getGlassCoverScale(
|
||||
viewportWidth: number,
|
||||
@@ -99,10 +539,9 @@ export function selectGlassOpticalRects(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
mobile: boolean,
|
||||
interactionPoint?: GlassInteractionPoint,
|
||||
): GlassOpticalRect[] {
|
||||
const viewportArea = Math.max(1, viewportWidth * viewportHeight)
|
||||
const maxCount = mobile ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||
const maxArea = viewportArea * (mobile ? 0.68 : 0.82)
|
||||
const visible = candidates
|
||||
.map(rect => {
|
||||
const left = Math.max(0, rect.x)
|
||||
@@ -120,10 +559,26 @@ export function selectGlassOpticalRects(
|
||||
}
|
||||
})
|
||||
.filter(candidate => candidate.visibleWidth >= 24 && candidate.visibleHeight >= 24)
|
||||
.sort((left, right) => left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea)
|
||||
.sort((left, right) => {
|
||||
if (interactionPoint) {
|
||||
const leftContainsInteraction =
|
||||
interactionPoint.x >= left.rect.x &&
|
||||
interactionPoint.x <= left.rect.x + left.rect.width &&
|
||||
interactionPoint.y >= left.rect.y &&
|
||||
interactionPoint.y <= left.rect.y + left.rect.height
|
||||
const rightContainsInteraction =
|
||||
interactionPoint.x >= right.rect.x &&
|
||||
interactionPoint.x <= right.rect.x + right.rect.width &&
|
||||
interactionPoint.y >= right.rect.y &&
|
||||
interactionPoint.y <= right.rect.y + right.rect.height
|
||||
|
||||
if (leftContainsInteraction !== rightContainsInteraction) return leftContainsInteraction ? -1 : 1
|
||||
}
|
||||
|
||||
return left.rect.rank - right.rect.rank || left.visibleArea - right.visibleArea
|
||||
})
|
||||
|
||||
const selected: typeof visible = []
|
||||
let selectedArea = 0
|
||||
|
||||
for (const candidate of visible) {
|
||||
if (selected.length >= maxCount) break
|
||||
@@ -138,10 +593,7 @@ export function selectGlassOpticalRects(
|
||||
)
|
||||
if (nested) continue
|
||||
|
||||
if (selected.length > 0 && selectedArea + candidate.visibleArea > maxArea) continue
|
||||
|
||||
selected.push(candidate)
|
||||
selectedArea += candidate.visibleArea
|
||||
}
|
||||
|
||||
return selected.map(candidate => candidate.rect)
|
||||
@@ -151,9 +603,20 @@ export function selectGlassOpticalRects(
|
||||
export function normalizeGlassOpticalRect(rect: GlassOpticalRect, viewportWidth: number, viewportHeight: number) {
|
||||
const safeWidth = Math.max(1, viewportWidth)
|
||||
const safeHeight = Math.max(1, viewportHeight)
|
||||
const radii = rect.radii.map(radius => Math.max(0, radius)) as GlassCornerRadii
|
||||
const [topLeft, topRight, bottomRight, bottomLeft] = radii
|
||||
const fitScale = (side: number, radiusSum: number) => (radiusSum > 0 ? Math.max(0, side) / radiusSum : 1)
|
||||
// CSS 会用同一个比例缩小全部圆角,保证任一边的相邻半径之和不超过该边长度。
|
||||
const radiusScale = Math.min(
|
||||
1,
|
||||
fitScale(rect.width, topLeft + topRight),
|
||||
fitScale(rect.width, bottomLeft + bottomRight),
|
||||
fitScale(rect.height, topLeft + bottomLeft),
|
||||
fitScale(rect.height, topRight + bottomRight),
|
||||
)
|
||||
|
||||
return {
|
||||
radius: Math.min(Math.max(0, rect.radius), Math.max(0, Math.min(rect.width, rect.height) / 2)),
|
||||
radii: radii.map(radius => radius * radiusScale) as GlassCornerRadii,
|
||||
rect: [
|
||||
rect.x / safeWidth,
|
||||
1 - (rect.y + rect.height) / safeHeight,
|
||||
|
||||
Reference in New Issue
Block a user