mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
feat(glass): add selectable dynamics modes (#639)
This commit is contained in:
@@ -1150,6 +1150,7 @@ onUnmounted(() => {
|
||||
v-if="shouldRenderGlassOpticalLayer"
|
||||
:appearance="effectiveGlassSettings.glassAppearance"
|
||||
:deformation-strength="opticalDeformationStrength"
|
||||
:dynamics-mode="effectiveGlassSettings.glassDynamicsMode"
|
||||
:flow-strength="opticalFlowStrength"
|
||||
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
||||
:reflection-strength="opticalReflectionStrength"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
previewGlassSettings,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerGlassAppearance,
|
||||
type ThemeCustomizerGlassDynamicsMode,
|
||||
type ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ const usesMobilePresentation = useGlassMobilePresentation()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftDynamicsMode = ref<ThemeCustomizerGlassDynamicsMode>(settings.value.glassDynamicsMode)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||
@@ -53,7 +55,8 @@ const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||
const isSaving = ref(false)
|
||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
||||
const showsDynamicTuning = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
|
||||
const showsDynamicsMode = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
|
||||
const showsDynamicTuning = computed(() => showsDynamicsMode.value && draftDynamicsMode.value !== 'off')
|
||||
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||
@@ -75,6 +78,7 @@ watch(
|
||||
if (value) {
|
||||
draftAppearance.value = settings.value.glassAppearance
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftDynamicsMode.value = settings.value.glassDynamicsMode
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||
@@ -120,6 +124,18 @@ const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
||||
const visiblePresetOptions = computed(() =>
|
||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
||||
)
|
||||
const dynamicsModeOptions: Array<{
|
||||
hint: string
|
||||
label: string
|
||||
value: ThemeCustomizerGlassDynamicsMode
|
||||
}> = [
|
||||
{ hint: 'theme.glassDynamicsModeFluidHint', label: 'theme.glassDynamicsModeFluid', value: 'fluid' },
|
||||
{ hint: 'theme.glassDynamicsModeRippleHint', label: 'theme.glassDynamicsModeRipple', value: 'ripple' },
|
||||
{ hint: 'theme.glassDynamicsModeOffHint', label: 'theme.glassDynamicsModeOff', value: 'off' },
|
||||
]
|
||||
const dynamicsModeHint = computed(
|
||||
() => dynamicsModeOptions.find(option => option.value === draftDynamicsMode.value)?.hint ?? '',
|
||||
)
|
||||
|
||||
/** 仅允许已实现的材质进入待保存设置。 */
|
||||
function updateAppearance(value: unknown) {
|
||||
@@ -138,11 +154,21 @@ function updateQuality(value: unknown) {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 仅切换动态效果草稿,六个具体参数和预设覆盖保持原值。 */
|
||||
function updateDynamicsMode(value: unknown) {
|
||||
const option = dynamicsModeOptions.find(item => item.value === value)
|
||||
if (!option) return
|
||||
|
||||
draftDynamicsMode.value = option.value
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassDynamicsMode: draftDynamicsMode.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
@@ -258,6 +284,7 @@ async function saveSettings() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassDynamicsMode: draftDynamicsMode.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
@@ -317,6 +344,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t('theme.glassAppearanceHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -362,6 +390,29 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t('theme.glassPresetHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="showsDynamicsMode" class="glass-settings-dialog__dynamics-mode-section">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDynamicsMode') }}</h3>
|
||||
<VBtnToggle
|
||||
:model-value="draftDynamicsMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__dynamics-mode"
|
||||
@update:model-value="updateDynamicsMode"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in dynamicsModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__dynamics-mode-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t(dynamicsModeHint) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="glass-settings-dialog__tuning">
|
||||
@@ -580,6 +631,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance,
|
||||
.glass-settings-dialog__dynamics-mode,
|
||||
.glass-settings-dialog__quality,
|
||||
.glass-settings-dialog__preset {
|
||||
display: grid;
|
||||
@@ -597,6 +649,11 @@ onScopeDispose(cancelGlassPreview)
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__dynamics-mode {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__quality {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -615,6 +672,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option,
|
||||
.glass-settings-dialog__dynamics-mode-option,
|
||||
.glass-settings-dialog__quality-option,
|
||||
.glass-settings-dialog__preset-option {
|
||||
border: 0 !important;
|
||||
@@ -630,11 +688,16 @@ onScopeDispose(cancelGlassPreview)
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__dynamics-mode-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__dynamics-mode-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||
|
||||
@@ -8,6 +8,8 @@ const dialogStub = {
|
||||
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||
}
|
||||
const toggleStub = {
|
||||
emits: ['update:modelValue'],
|
||||
name: 'VBtnToggle',
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
}
|
||||
@@ -31,6 +33,7 @@ const mocks = vi.hoisted(() => ({
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -78,6 +81,7 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.display.smAndDown.value = false
|
||||
mocks.settings.value.glassAppearance = 'clear'
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassDynamicsMode = 'fluid'
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassPresetOverrides = {}
|
||||
@@ -174,6 +178,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 79,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 77,
|
||||
glassPreset: 'liquid',
|
||||
glassPresetOverrides: {
|
||||
@@ -218,6 +223,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -255,6 +261,7 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted and records its combination override', async () => {
|
||||
@@ -335,6 +342,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 69,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 62,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -364,6 +372,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -374,6 +383,7 @@ describe('GlassSettingsDialog', () => {
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(6)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('fluid')
|
||||
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
|
||||
'theme.glassTransparencyStrength',
|
||||
'theme.glassTransmissionStrength',
|
||||
@@ -386,6 +396,7 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
it('restores motion tuning when a mobile presentation returns to desktop', async () => {
|
||||
mocks.usesMobilePresentation!.value = true
|
||||
mocks.settings.value.glassDynamicsMode = 'ripple'
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
@@ -393,6 +404,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -407,6 +419,7 @@ describe('GlassSettingsDialog', () => {
|
||||
'theme.glassReflectionStrength',
|
||||
])
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
|
||||
expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint')
|
||||
expect(wrapper.text()).toContain('theme.glassQualityMobileHint')
|
||||
@@ -416,7 +429,63 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(true)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('ripple')
|
||||
expect(wrapper.text()).toContain('theme.glassAppearanceHint')
|
||||
expect(wrapper.text()).toContain('theme.glassPresetHint')
|
||||
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
|
||||
expect(wrapper.text()).toContain('theme.glassOpticalStrengthHint')
|
||||
})
|
||||
|
||||
it('keeps optical parameters while switching modes and hides motion tuning only when off', async () => {
|
||||
mocks.settings.value.glassDynamicsMode = 'ripple'
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassDeformationStrength = 62
|
||||
mocks.settings.value.glassFlowStrength = 58
|
||||
mocks.settings.value.glassReflectionStrength = 44
|
||||
mocks.settings.value.glassTransmissionStrength = 67
|
||||
mocks.settings.value.glassTranslationStrength = 76
|
||||
mocks.settings.value.glassTransparencyStrength = 53
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const modeControl = wrapper
|
||||
.findAllComponents({ name: 'VBtnToggle' })
|
||||
.find(component => component.classes().includes('glass-settings-dialog__dynamics-mode'))
|
||||
if (!modeControl) throw new Error('dynamics mode control was not rendered')
|
||||
|
||||
expect(modeControl.attributes('data-model-value')).toBe('ripple')
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
||||
|
||||
modeControl.vm.$emit('update:modelValue', 'off')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 62,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 58,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
glassReflectionStrength: 44,
|
||||
glassTransmissionStrength: 67,
|
||||
glassTranslationStrength: 76,
|
||||
glassTransparencyStrength: 53,
|
||||
})
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(3)
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import type {
|
||||
ThemeCustomizerGlassAppearance,
|
||||
ThemeCustomizerGlassDynamicsMode,
|
||||
ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
import {
|
||||
@@ -18,6 +23,8 @@ const props = defineProps<{
|
||||
deformationStrength: number
|
||||
/** 用户选择的轨迹、尾波与惯性强度。 */
|
||||
flowStrength: number
|
||||
/** 用户保存的动态效果模式;能力降级不会回写该选择。 */
|
||||
dynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
||||
@@ -74,7 +81,16 @@ const emit = defineEmits<{
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const usesMobilePresentation = useGlassMobilePresentation()
|
||||
const dynamicsActive = computed(() => !usesMobilePresentation.value)
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const compositeFailureLatched = ref(false)
|
||||
const compositeRecoveryPending = ref(false)
|
||||
const presentationMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
|
||||
usesMobilePresentation.value || preferredMotion.value === 'reduce' ? 'off' : props.dynamicsMode,
|
||||
)
|
||||
const effectiveDynamicsMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
|
||||
compositeFailureLatched.value ? 'off' : presentationMode.value,
|
||||
)
|
||||
const dynamicsActive = computed(() => effectiveDynamicsMode.value !== 'off')
|
||||
const interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
||||
@@ -84,6 +100,7 @@ const fixedRenderer = useGlassOpticalRenderer({
|
||||
canvas: fixedCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
dynamicsActive,
|
||||
dynamicsMode: effectiveDynamicsMode,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
quality: () => props.quality,
|
||||
@@ -109,6 +126,7 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
canvas: scrollCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
dynamicsActive,
|
||||
dynamicsMode: effectiveDynamicsMode,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
pageMotion: pagePresentationMotion.reader,
|
||||
@@ -130,16 +148,45 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
syncDocumentState: false,
|
||||
})
|
||||
|
||||
/** 用户显式改选动态策略时,用同一代次重建两个已进入复合回退的 renderer。 */
|
||||
function retryCompositeRenderers() {
|
||||
compositeRecoveryPending.value = true
|
||||
compositeFailureLatched.value = false
|
||||
void Promise.allSettled([fixedRenderer.retryAfterFailure(), scrollRenderer.retryAfterFailure()])
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.dynamicsMode,
|
||||
(mode, previousMode) => {
|
||||
if (mode === previousMode || (!compositeFailureLatched.value && !compositeRecoveryPending.value)) return
|
||||
|
||||
retryCompositeRenderers()
|
||||
},
|
||||
)
|
||||
|
||||
const rendererState = ref<GlassRendererState>('loading')
|
||||
|
||||
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
||||
watchEffect(() => {
|
||||
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
||||
const state: GlassRendererState = states.every(value => value === 'ready')
|
||||
? 'ready'
|
||||
: states.some(value => value === 'loading')
|
||||
? 'loading'
|
||||
: 'fallback'
|
||||
const allReady = states.every(value => value === 'ready')
|
||||
const anyFallback = states.some(value => value === 'fallback')
|
||||
const anyLoading = states.some(value => value === 'loading')
|
||||
if (compositeRecoveryPending.value) {
|
||||
if (allReady) compositeRecoveryPending.value = false
|
||||
else if (anyFallback && !anyLoading) {
|
||||
compositeRecoveryPending.value = false
|
||||
compositeFailureLatched.value = true
|
||||
}
|
||||
} else if (anyFallback) compositeFailureLatched.value = true
|
||||
else if (compositeFailureLatched.value && allReady) compositeFailureLatched.value = false
|
||||
const state: GlassRendererState = compositeRecoveryPending.value
|
||||
? 'loading'
|
||||
: compositeFailureLatched.value
|
||||
? 'fallback'
|
||||
: allReady
|
||||
? 'ready'
|
||||
: 'loading'
|
||||
|
||||
setGlassRendererState(rendererState, state)
|
||||
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
||||
@@ -151,6 +198,11 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.dataset.glassDynamicsMode = props.dynamicsMode
|
||||
document.documentElement.dataset.glassDynamicsEffectiveMode = effectiveDynamicsMode.value
|
||||
})
|
||||
|
||||
let lastPreparedAcknowledgement = ''
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
@@ -287,6 +339,8 @@ watchEffect(() => {
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||
delete document.documentElement.dataset.glassDynamicsMode
|
||||
delete document.documentElement.dataset.glassDynamicsEffectiveMode
|
||||
setGlassRendererState(rendererState, 'fallback')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -143,6 +143,7 @@ const hasAppModeCustomization = computed(() => {
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
|
||||
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
|
||||
settings.value.glassDynamicsMode !== defaultAppModeGlassSettings.glassDynamicsMode ||
|
||||
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
|
||||
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
|
||||
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||
|
||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
const rendererInitialStates = vi.hoisted(() => [] as string[])
|
||||
const rendererResults = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
@@ -19,6 +20,7 @@ const rendererResults = vi.hoisted(
|
||||
preparedWallpaperRevision: { value: number }
|
||||
preparedWallpaperUrl: { value: string }
|
||||
renderedFrames: { value: number }
|
||||
retryAfterFailure: ReturnType<typeof vi.fn>
|
||||
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
|
||||
state: { value: string }
|
||||
}>,
|
||||
@@ -61,7 +63,12 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
||||
preparedWallpaperRevision: ref(0),
|
||||
preparedWallpaperUrl: ref(''),
|
||||
renderedFrames: ref(0),
|
||||
state: ref('ready'),
|
||||
state: ref(rendererInitialStates.shift() ?? 'ready'),
|
||||
retryAfterFailure: vi.fn(() => {
|
||||
result.state.value = 'loading'
|
||||
|
||||
return Promise.resolve()
|
||||
}),
|
||||
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
|
||||
return (
|
||||
result.state.value === 'ready' &&
|
||||
@@ -114,6 +121,7 @@ vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
|
||||
|
||||
afterEach(() => {
|
||||
mobilePresentationState.current!.value = false
|
||||
rendererInitialStates.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -126,6 +134,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
@@ -146,6 +155,7 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'fluid')).toBe(true)
|
||||
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
@@ -161,6 +171,37 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
|
||||
it('does not latch a composite failure while both contexts are initially loading', () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
rendererInitialStates.push('loading', 'loading')
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
|
||||
expect(setRendererState).toHaveBeenCalledWith(expect.any(Object), 'loading')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps both material contexts while disabling dynamics on mobile presentations', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
@@ -169,6 +210,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'high',
|
||||
@@ -186,11 +228,15 @@ describe('GlassOpticalLayer', () => {
|
||||
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(2)
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
|
||||
mobilePresentationState.current!.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -210,6 +256,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 0,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 7,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -267,6 +314,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 10,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -322,6 +370,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 8,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 8,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -348,13 +397,34 @@ describe('GlassOpticalLayer', () => {
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
||||
|
||||
scrollRenderer.state.value = 'loading'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
|
||||
fixedRenderer.state.value = 'loading'
|
||||
scrollRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
|
||||
fixedRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
|
||||
expect(requestFrame).toHaveBeenCalledOnce()
|
||||
;(activationCallback as FrameRequestCallback | null)?.(640)
|
||||
await nextTick()
|
||||
@@ -365,6 +435,93 @@ describe('GlassOpticalLayer', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retries both contexts when the requested dynamics mode changes after a composite failure', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
|
||||
await wrapper.setProps({ dynamicsMode: 'fluid' })
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
|
||||
|
||||
fixedRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
|
||||
|
||||
scrollRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'ready')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the composite fallback without retrying in a loop when explicit recovery fails', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.setProps({ dynamicsMode: 'fluid' })
|
||||
await nextTick()
|
||||
fixedRenderer.state.value = 'ready'
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'returns false',
|
||||
@@ -392,6 +549,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 9,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 9,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
||||
import type { ShaderMaterial, Vector2, WebGLRenderTarget } from 'three'
|
||||
import type { Object3D, ShaderMaterial, Vector2, WebGLRenderTarget } from 'three'
|
||||
|
||||
const wallpaperToneMocks = vi.hoisted(() => ({
|
||||
load: vi.fn(),
|
||||
@@ -294,6 +294,30 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('attaches shared input only while a dynamic presentation is active', async () => {
|
||||
const active = ref(false)
|
||||
const scrollListener = vi.fn()
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
const source = useGlassOpticalInteractionSource(active)
|
||||
source.subscribe('scroll', scrollListener)
|
||||
})
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400 }))
|
||||
expect(scrollListener).not.toHaveBeenCalled()
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 420, clientY: 420 }))
|
||||
expect(scrollListener).toHaveBeenCalledOnce()
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 440, clientY: 440 }))
|
||||
expect(scrollListener).toHaveBeenCalledOnce()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('detects a target surface added directly', () => {
|
||||
const surface = document.createElement('section')
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
@@ -2269,6 +2293,90 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clears an expired ripple field before the first resumed frame', async () => {
|
||||
const three = await import('three')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
let now = 0
|
||||
let interactionListener: ((event: PointerEvent | TouchEvent) => void) | null = null
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode: ref('ripple'),
|
||||
interactionSource: {
|
||||
subscribe: vi.fn((_space, listener) => {
|
||||
interactionListener = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
},
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
now = 16 + pass * 16
|
||||
scheduledCallbacks.forEach(callback => callback(now))
|
||||
}
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
|
||||
;(interactionListener as ((event: PointerEvent) => void) | null)?.({
|
||||
clientX: 200,
|
||||
clientY: 180,
|
||||
pointerType: 'mouse',
|
||||
timeStamp: 100,
|
||||
type: 'pointermove',
|
||||
} as PointerEvent)
|
||||
const [interactionFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
interactionFrame(116.667)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(1)
|
||||
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
now = 1000
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.waitFor(() => expect(uniforms.uHasRippleTexture.value).toBe(0))
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
for (let pass = 0; pass < 8 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
now = 1016 + pass * 16
|
||||
scheduledCallbacks.forEach(callback => callback(now))
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
surface.remove()
|
||||
})
|
||||
|
||||
it('coalesces visibility, focus, and pageshow into one renderer resume', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -2556,6 +2664,442 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps off mode static, unsubscribed and reversible without changing material rendering', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const unsubscribe = vi.fn()
|
||||
const interactionSource = {
|
||||
subscribe: vi.fn(() => unsubscribe),
|
||||
}
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength: ref(80),
|
||||
dynamicsMode,
|
||||
flowStrength: ref(80),
|
||||
interactionSource,
|
||||
quality: ref('high'),
|
||||
reflectionStrength: ref(80),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
transmissionStrength: ref(80),
|
||||
translationStrength: ref(80),
|
||||
transparencyStrength: ref(80),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(interactionSource.subscribe).not.toHaveBeenCalled()
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(
|
||||
call =>
|
||||
call[0] as unknown as {
|
||||
children?: Array<{ material?: { uniforms?: Record<string, { value: unknown }> } }>
|
||||
},
|
||||
)
|
||||
.find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode)
|
||||
const uniforms = mainScene?.children?.[0]?.material?.uniforms
|
||||
expect(uniforms?.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms?.uTranslationStrength.value).toBe(0)
|
||||
expect(uniforms?.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms?.uFlowStrength.value).toBe(0)
|
||||
expect(uniforms?.uTrailCount.value).toBe(0)
|
||||
expect(uniforms?.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms?.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms?.uReflectionStrength.value).toBeGreaterThan(0)
|
||||
|
||||
const renderedFrames = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 300, clientY: 300 }))
|
||||
dispatchTouchEvent('touchstart', [{ clientX: 300, clientY: 300, identifier: 7 }])
|
||||
dispatchTouchEvent('touchmove', [{ clientX: 320, clientY: 320, identifier: 7 }])
|
||||
expect(renderer?.renderedFrames.value).toBe(renderedFrames)
|
||||
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => expect(interactionSource.subscribe).toHaveBeenCalledOnce())
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
|
||||
expect(interactionSource.subscribe).toHaveBeenCalledOnce()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('preserves the baseline first fluid velocity but suppresses the first velocity after a mode reset', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
expect(Math.hypot(uniforms.uPointerVelocity.value.x, uniforms.uPointerVelocity.value.y)).toBeGreaterThan(0)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(2))
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(0))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 260, clientY: 180 }))
|
||||
expect(uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 })
|
||||
scope.stop()
|
||||
surface.remove()
|
||||
})
|
||||
|
||||
it('allocates only the selected temporal field and releases it on every mode change', async () => {
|
||||
const three = await import('three')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
expect(baselineCompileCalls).toBeGreaterThan(0)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls)
|
||||
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 4)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 6))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('reinitializes a disposed fallback once under the latest mode when explicitly retried', async () => {
|
||||
const three = await import('three')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockRejectedValueOnce(new Error('ripple unavailable'))
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback'))
|
||||
|
||||
dynamicsMode.value = 'fluid'
|
||||
await nextTick()
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
|
||||
await renderer?.retryAfterFailure()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 2)
|
||||
expect(warn).toHaveBeenCalledWith('玻璃动态策略切换失败,已回退标准材质:', expect.any(Error))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('disposes a late ripple compilation result after a newer off selection wins', async () => {
|
||||
const three = await import('three')
|
||||
let finishCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishCompilation = resolve
|
||||
}),
|
||||
)
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(
|
||||
call =>
|
||||
call[0] as unknown as {
|
||||
children?: Array<{ material?: { uniforms?: Record<string, { value: unknown }> } }>
|
||||
},
|
||||
)
|
||||
.find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode)
|
||||
const uniforms = mainScene?.children?.[0]?.material?.uniforms
|
||||
expect(uniforms?.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms?.uRippleTexture.value).toBeNull()
|
||||
expect(uniforms?.uHasRippleTexture.value).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores a late ripple compilation failure after a newer off selection wins', async () => {
|
||||
const three = await import('three')
|
||||
let rejectCompilation: ((error: Error) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<never>((_, reject) => {
|
||||
rejectCompilation = reject
|
||||
}),
|
||||
)
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
;(rejectCompilation as ((error: Error) => void) | null)?.(new Error('stale ripple compile failed'))
|
||||
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let finishCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishCompilation = resolve
|
||||
}),
|
||||
)
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
||||
calls.filter(([event]) => String(event) === eventName).length
|
||||
const scope = effectScope()
|
||||
scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce())
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
|
||||
scope.stop()
|
||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
expect(ResizeObserverMock.instances).toHaveLength(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
|
||||
})
|
||||
|
||||
it('recovers once when context loss occurs during initial ripple compilation', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let finishFirstCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishFirstCompilation = resolve
|
||||
}),
|
||||
)
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
||||
calls.filter(([event]) => String(event) === eventName).length
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce())
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(3)
|
||||
const listenerCountsBeforeLateResult = {
|
||||
contextLost: countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost'),
|
||||
pointerMove: countListenerAdds(addWindowListener.mock.calls, 'pointermove'),
|
||||
resize: countListenerAdds(addWindowListener.mock.calls, 'resize'),
|
||||
}
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
|
||||
;(finishFirstCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
expect(ResizeObserverMock.instances).toHaveLength(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(
|
||||
listenerCountsBeforeLateResult.contextLost,
|
||||
)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(
|
||||
listenerCountsBeforeLateResult.pointerMove,
|
||||
)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(listenerCountsBeforeLateResult.resize)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('restores the latest off mode after an active ripple renderer loses context', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const unsubscribe = vi.fn()
|
||||
const subscribe = vi.fn(() => unsubscribe)
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('ripple')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode,
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
const compileCallsBeforeLoss = compileAsync.mock.calls.length
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
await renderer?.retryAfterFailure()
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss)
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss + 1)
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
const framesBeforePointer = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
await nextTick()
|
||||
expect(renderer?.renderedFrames.value).toBe(framesBeforePointer)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
|
||||
stubMediaPreferences({ coarsePointer: true })
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||
@@ -2794,6 +3338,67 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clears ripple state before native scroll presentation takes ownership', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
|
||||
}
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(1)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
expect(callbacks.size).toBe(1)
|
||||
const rippleFrames = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
rippleFrames.forEach(callback => callback(performance.now() + 16))
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(1)
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 }))
|
||||
|
||||
expect(callbacks.size).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores scroll intent and movement that cannot move a managed glass surface', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
@@ -3516,6 +4121,15 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDynamicsOnly')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float rippleGradientEnergy = smoothstep(0.003, 0.08, rippleGradientLength)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'rippleGradient * mix(230.0, 335.0, uQuality) * uRippleDeformationStrength',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'uAppearance > 1.5 ? 1.25 : (uAppearance > 0.5 ? 0.86 : 0.72)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float sharedWaveDensity = mix(2.81, 1.63')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)',
|
||||
@@ -3750,4 +4364,122 @@ describe('glass optical surface discovery', () => {
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps static material rendering while off owns no interaction subscription or dynamic output', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const subscribe = vi.fn(() => vi.fn())
|
||||
const deformationStrength = ref(80)
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const reflectionStrength = ref(40)
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength,
|
||||
dynamicsMode,
|
||||
flowStrength: ref(80),
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('high'),
|
||||
reflectionStrength,
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength: ref(80),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = scene.children[0].material!.uniforms
|
||||
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
expect(uniforms.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms.uTranslationStrength.value).toBe(0)
|
||||
expect(uniforms.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uFlowStrength.value).toBe(0)
|
||||
expect(uniforms.uRippleDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uTrailCount.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(0)
|
||||
|
||||
const framesBeforePointer = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
await nextTick()
|
||||
expect(renderer?.renderedFrames.value).toBe(framesBeforePointer)
|
||||
|
||||
deformationStrength.value = 100
|
||||
reflectionStrength.value = 100
|
||||
await nextTick()
|
||||
expect(uniforms.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uRippleDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps fluid and ripple resources mutually exclusive across rapid mode switches', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const subscribe = vi.fn(() => vi.fn())
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength: ref(70),
|
||||
dynamicsMode,
|
||||
flowStrength: ref(70),
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength: ref(70),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = scene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(1)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(1))
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uMaxRefractionPixels.value).toBeCloseTo(28)
|
||||
expect(
|
||||
render.mock.calls.some(call => {
|
||||
const rippleScene = call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }
|
||||
return rippleScene.children[0]?.material?.fragmentShader.includes('uImpulseSigma')
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => {
|
||||
expect(uniforms.uDynamicsMode.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(1)
|
||||
})
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(subscribe).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(48)
|
||||
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||
expect(settings.glassFlowStrength).toBe(48)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toEqual({})
|
||||
@@ -71,6 +72,14 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
await customizer.setGlassDynamicsMode('off')
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(false)
|
||||
expect(customizer.isCustomized.value).toBe(true)
|
||||
|
||||
await customizer.resetSettings()
|
||||
expect(customizer.settings.value.glassDynamicsMode).toBe('ripple')
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -78,6 +87,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(getDefaultGlassCustomizerSettings('css')).toEqual({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 48,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 48,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -101,15 +111,27 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance)
|
||||
})
|
||||
|
||||
it.each(['fluid', 'ripple', 'off'] as const)('preserves the %s dynamics mode contract', glassDynamicsMode => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassDynamicsMode }))
|
||||
|
||||
expect(readThemeCustomizerSettings().glassDynamicsMode).toBe(glassDynamicsMode)
|
||||
})
|
||||
|
||||
it('falls back when stored glass settings are invalid', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }),
|
||||
JSON.stringify({
|
||||
glassAppearance: 'opaque',
|
||||
glassDynamicsMode: 'elastic',
|
||||
glassPreset: 'elastic',
|
||||
glassQuality: 'ultra',
|
||||
}),
|
||||
)
|
||||
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
@@ -184,9 +206,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70)
|
||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
||||
0.48,
|
||||
)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
|
||||
@@ -195,10 +215,12 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('previews glass settings without persisting them', () => {
|
||||
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||
|
||||
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'tinted', glassDynamicsMode: 'ripple' })
|
||||
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe('clear')
|
||||
expect(readThemeCustomizerSettings().glassDynamicsMode).toBe('ripple')
|
||||
expect(useEffectiveGlassSettings().value.glassDynamicsMode).toBe('ripple')
|
||||
expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview)
|
||||
})
|
||||
|
||||
@@ -208,6 +230,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -232,6 +255,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -288,6 +312,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 88,
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
@@ -307,6 +332,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTransmissionStrength: 92,
|
||||
@@ -320,6 +346,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 44,
|
||||
glassPresetOverrides: {
|
||||
'tinted:balanced:natural': {
|
||||
@@ -338,6 +365,40 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('switches dynamics mode without changing preset ownership or optical parameters', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 73,
|
||||
glassFlowStrength: 61,
|
||||
glassPreset: 'glide',
|
||||
glassPresetOverrides: {
|
||||
'tinted:high:glide': {
|
||||
deformation: 73,
|
||||
flow: 61,
|
||||
reflection: 47,
|
||||
transmission: 68,
|
||||
translation: 82,
|
||||
transparency: 59,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 47,
|
||||
glassTransmissionStrength: 68,
|
||||
glassTranslationStrength: 82,
|
||||
glassTransparencyStrength: 59,
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const before = readThemeCustomizerSettings()
|
||||
|
||||
await customizer.setGlassDynamicsMode('off')
|
||||
expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'off' })
|
||||
|
||||
await customizer.setGlassDynamicsMode('ripple')
|
||||
expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'ripple' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies the same preset for a new material and quality while preset-managed', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,8 @@ export const themeCustomizerShadowLevels = [
|
||||
|
||||
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
/** 玻璃动态效果的持久化选择;关闭模式仍保留用户配置的动态参数。 */
|
||||
export type ThemeCustomizerGlassDynamicsMode = 'fluid' | 'ripple' | 'off'
|
||||
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
|
||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||
@@ -79,6 +81,8 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
|
||||
export interface ThemeCustomizerSettings {
|
||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||
glassAppearance: ThemeCustomizerGlassAppearance
|
||||
/** 玻璃动态效果模式,与六参数预设矩阵保持独立。 */
|
||||
glassDynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
||||
glassDeformationStrength: number
|
||||
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
||||
@@ -117,6 +121,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||
const validGlassDynamicsModes: ThemeCustomizerGlassDynamicsMode[] = ['fluid', 'ripple', 'off']
|
||||
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
||||
@@ -138,6 +143,7 @@ type DefaultGlassCustomizerSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassDynamicsMode'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
@@ -176,6 +182,7 @@ export function getDefaultGlassCustomizerSettings(
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -287,6 +294,9 @@ function normalizeThemeCustomizerSettings(
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassDeformationStrength,
|
||||
),
|
||||
glassDynamicsMode: validGlassDynamicsModes.includes(settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||
? (settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||
: fallback.glassDynamicsMode,
|
||||
glassFlowStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassFlowStrength,
|
||||
settings.glassMotionStrength,
|
||||
@@ -368,6 +378,7 @@ type ThemeCustomizerGlassSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassDynamicsMode'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
@@ -382,6 +393,7 @@ const effectiveGlassSettings = computed(() => ({
|
||||
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||
glassDeformationStrength:
|
||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||
glassDynamicsMode: glassPreviewState.value?.glassDynamicsMode ?? settingsState.value.glassDynamicsMode,
|
||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
||||
@@ -591,6 +603,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
|
||||
glassPreviewState.value = {
|
||||
glassAppearance: previewSettings.glassAppearance,
|
||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||
glassDynamicsMode: previewSettings.glassDynamicsMode,
|
||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||
glassPreset: previewSettings.glassPreset,
|
||||
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
||||
@@ -645,6 +658,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
return (
|
||||
settings.glassAppearance === defaults.glassAppearance &&
|
||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||
settings.glassDynamicsMode === defaults.glassDynamicsMode &&
|
||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||
settings.glassPreset === defaults.glassPreset &&
|
||||
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
||||
@@ -762,6 +776,11 @@ export function useThemeCustomizer() {
|
||||
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
||||
}
|
||||
|
||||
/** 切换玻璃动态效果,不改写当前预设归属或六个具体参数。 */
|
||||
function setGlassDynamicsMode(glassDynamicsMode: ThemeCustomizerGlassDynamicsMode) {
|
||||
return updateSettings({ glassDynamicsMode })
|
||||
}
|
||||
|
||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
||||
@@ -901,6 +920,7 @@ export function useThemeCustomizer() {
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassDeformationStrength,
|
||||
setGlassDynamicsMode,
|
||||
setGlassFlowStrength,
|
||||
setGlassPreset,
|
||||
setGlassQuality,
|
||||
|
||||
@@ -152,6 +152,8 @@ export default {
|
||||
glassAppearanceClear: 'Clear',
|
||||
glassAppearanceTinted: 'Tinted',
|
||||
glassAppearanceFrosted: 'Frosted',
|
||||
glassAppearanceHint:
|
||||
'Clear emphasizes wallpaper detail, Tinted adds color coverage, and Frosted uses blur diffusion for a denser glass surface.',
|
||||
glassQuality: 'Quality',
|
||||
glassQualityCss: 'Standard',
|
||||
glassQualityBalanced: 'Balanced',
|
||||
@@ -165,6 +167,14 @@ export default {
|
||||
glassPresetNatural: 'Natural',
|
||||
glassPresetGlide: 'Glide',
|
||||
glassPresetLiquid: 'Liquid',
|
||||
glassPresetHint: 'Natural stays balanced, Glide favors smooth movement, and Liquid adds deformation and inertia.',
|
||||
glassDynamicsMode: 'Motion Effect',
|
||||
glassDynamicsModeFluid: 'Fluid',
|
||||
glassDynamicsModeRipple: 'Ripple',
|
||||
glassDynamicsModeOff: 'Off',
|
||||
glassDynamicsModeFluidHint: 'Creates continuous flow and refraction that follow the pointer.',
|
||||
glassDynamicsModeRippleHint: 'Creates ripples that spread across nearby glass surfaces as the pointer moves.',
|
||||
glassDynamicsModeOffHint: 'Keeps the static material without pointer-driven motion.',
|
||||
glassMaterialTuning: 'Material',
|
||||
glassDynamicTuning: 'Motion',
|
||||
glassTranslationStrength: 'Sample Translation',
|
||||
|
||||
@@ -150,6 +150,7 @@ export default {
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色调',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassAppearanceHint: '透明突出壁纸纹理,色调增加颜色覆盖,磨砂通过模糊扩散呈现更厚的玻璃质感。',
|
||||
glassQuality: '质量',
|
||||
glassQualityCss: '标准',
|
||||
glassQualityBalanced: '均衡',
|
||||
@@ -162,6 +163,14 @@ export default {
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液态',
|
||||
glassPresetHint: '自然均衡克制,滑移强调顺畅移动,液态增强形变与惯性。',
|
||||
glassDynamicsMode: '动态效果',
|
||||
glassDynamicsModeFluid: '流体',
|
||||
glassDynamicsModeRipple: '水漾',
|
||||
glassDynamicsModeOff: '关闭',
|
||||
glassDynamicsModeFluidHint: '跟随指针形成连续的流动与折射反馈。',
|
||||
glassDynamicsModeRippleHint: '指针经过玻璃时产生向相邻表面扩散的水纹。',
|
||||
glassDynamicsModeOffHint: '保留静态材质,不响应指针动态。',
|
||||
glassMaterialTuning: '材质参数',
|
||||
glassDynamicTuning: '动态参数',
|
||||
glassTranslationStrength: '采样平移',
|
||||
|
||||
@@ -150,6 +150,7 @@ export default {
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色調',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassAppearanceHint: '透明強調桌布紋理,色調增加色彩覆蓋,磨砂透過模糊擴散呈現更厚實的玻璃質感。',
|
||||
glassQuality: '品質',
|
||||
glassQualityCss: '標準',
|
||||
glassQualityBalanced: '均衡',
|
||||
@@ -162,6 +163,14 @@ export default {
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液態',
|
||||
glassPresetHint: '自然均衡克制,滑移強調順暢移動,液態增強形變與慣性。',
|
||||
glassDynamicsMode: '動態效果',
|
||||
glassDynamicsModeFluid: '流體',
|
||||
glassDynamicsModeRipple: '水漾',
|
||||
glassDynamicsModeOff: '關閉',
|
||||
glassDynamicsModeFluidHint: '跟隨指標形成連續的流動與折射回饋。',
|
||||
glassDynamicsModeRippleHint: '指標經過玻璃時產生向相鄰表面擴散的水紋。',
|
||||
glassDynamicsModeOffHint: '保留靜態材質,不回應指標動態。',
|
||||
glassMaterialTuning: '材質參數',
|
||||
glassDynamicTuning: '動態參數',
|
||||
glassTranslationStrength: '採樣平移',
|
||||
|
||||
187
src/rendering/glass/__tests__/glassFluidDynamics.spec.ts
Normal file
187
src/rendering/glass/__tests__/glassFluidDynamics.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGlassFluidDynamics, GLASS_FLUID_FIELD_FRAGMENT_SHADER } from '@/rendering/glass/glassFluidDynamics'
|
||||
|
||||
class FakeVector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0,
|
||||
) {}
|
||||
|
||||
set(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRenderTarget {
|
||||
static instances: FakeRenderTarget[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
height: number
|
||||
readonly setSize = vi.fn((width: number, height: number) => {
|
||||
this.width = width
|
||||
this.height = height
|
||||
})
|
||||
readonly texture = {}
|
||||
width: number
|
||||
|
||||
constructor(
|
||||
width: number,
|
||||
height: number,
|
||||
readonly options: Record<string, unknown>,
|
||||
) {
|
||||
this.height = height
|
||||
this.width = width
|
||||
FakeRenderTarget.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShaderMaterial {
|
||||
static instances: FakeShaderMaterial[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
readonly fragmentShader: string
|
||||
readonly uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
|
||||
this.fragmentShader = options.fragmentShader
|
||||
this.uniforms = options.uniforms
|
||||
FakeShaderMaterial.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeScene {
|
||||
readonly children: FakeMesh[] = []
|
||||
|
||||
add(mesh: FakeMesh) {
|
||||
this.children.push(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMesh {
|
||||
frustumCulled = true
|
||||
|
||||
constructor(
|
||||
readonly geometry: unknown,
|
||||
readonly material: FakeShaderMaterial,
|
||||
) {}
|
||||
}
|
||||
|
||||
function createFluidHarness() {
|
||||
let currentTarget: FakeRenderTarget | null = null
|
||||
const pointer = new FakeVector2(0.25, 0.75)
|
||||
const velocity = new FakeVector2(0.1, -0.2)
|
||||
const renderer = {
|
||||
render: vi.fn(),
|
||||
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
|
||||
currentTarget = target
|
||||
}),
|
||||
setScissorTest: vi.fn(),
|
||||
}
|
||||
const three = {
|
||||
LinearFilter: 1001,
|
||||
Mesh: FakeMesh,
|
||||
Scene: FakeScene,
|
||||
ShaderMaterial: FakeShaderMaterial,
|
||||
Vector2: FakeVector2,
|
||||
WebGLRenderTarget: FakeRenderTarget,
|
||||
} as unknown as typeof import('three')
|
||||
|
||||
return {
|
||||
create: () =>
|
||||
createGlassFluidDynamics({
|
||||
camera: {} as never,
|
||||
geometry: {} as never,
|
||||
pointer: pointer as never,
|
||||
renderer: renderer as never,
|
||||
three,
|
||||
velocity: velocity as never,
|
||||
}),
|
||||
getCurrentTarget: () => currentTarget,
|
||||
pointer,
|
||||
renderer,
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRenderTarget.instances = []
|
||||
FakeShaderMaterial.instances = []
|
||||
})
|
||||
|
||||
describe('glass fluid dynamics', () => {
|
||||
it('owns exactly one two-target field and reuses the shared pointer vectors', () => {
|
||||
const harness = createFluidHarness()
|
||||
const dynamics = harness.create()
|
||||
const material = FakeShaderMaterial.instances[0]
|
||||
|
||||
expect(FakeRenderTarget.instances).toHaveLength(2)
|
||||
expect(FakeRenderTarget.instances.map(target => target.options)).toEqual([
|
||||
{
|
||||
depthBuffer: false,
|
||||
magFilter: 1001,
|
||||
minFilter: 1001,
|
||||
stencilBuffer: false,
|
||||
},
|
||||
{
|
||||
depthBuffer: false,
|
||||
magFilter: 1001,
|
||||
minFilter: 1001,
|
||||
stencilBuffer: false,
|
||||
},
|
||||
])
|
||||
expect(material.fragmentShader).toBe(GLASS_FLUID_FIELD_FRAGMENT_SHADER)
|
||||
expect(material.uniforms.uPointer.value).toBe(harness.pointer)
|
||||
expect(material.uniforms.uVelocity.value).toBe(harness.velocity)
|
||||
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('resizes, advances, swaps and clears its private temporal field', () => {
|
||||
const harness = createFluidHarness()
|
||||
const dynamics = harness.create()
|
||||
const material = FakeShaderMaterial.instances[0]
|
||||
const [firstTarget, secondTarget] = FakeRenderTarget.instances
|
||||
|
||||
dynamics.resize(800, 600, 1200, 600)
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[200, 150],
|
||||
[200, 150],
|
||||
])
|
||||
expect(material.uniforms.uTexelSize.value).toMatchObject({ x: 1 / 200, y: 1 / 150 })
|
||||
expect(material.uniforms.uViewportAspect.value).toBe(2)
|
||||
|
||||
dynamics.setFrameParameters(0.8, 0.6)
|
||||
const texture = dynamics.step()
|
||||
|
||||
expect(harness.renderer.setScissorTest).toHaveBeenCalledWith(false)
|
||||
expect(material.uniforms.uPrevious.value).toBe(firstTarget.texture)
|
||||
expect(harness.renderer.setRenderTarget.mock.calls).toEqual([[secondTarget], [null]])
|
||||
expect(harness.renderer.render).toHaveBeenCalledOnce()
|
||||
expect(harness.getCurrentTarget()).toBeNull()
|
||||
expect(texture).toBe(secondTarget.texture)
|
||||
|
||||
dynamics.finishFrame()
|
||||
expect(material.uniforms.uInjection.value).toBe(0)
|
||||
expect(material.uniforms.uDecay.value).toBe(0.8)
|
||||
|
||||
dynamics.clearInput()
|
||||
expect(material.uniforms.uDecay.value).toBe(0)
|
||||
expect(material.uniforms.uInjection.value).toBe(0)
|
||||
|
||||
dynamics.dispose()
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(material.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the established field injection and decay equations', () => {
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('previousEnergy * uDecay')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 437.500')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 262.500')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('injection * 0.44')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).not.toContain('uImpulse')
|
||||
})
|
||||
})
|
||||
385
src/rendering/glass/__tests__/glassRippleDynamics.spec.ts
Normal file
385
src/rendering/glass/__tests__/glassRippleDynamics.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createGlassRippleDynamics,
|
||||
RIPPLE_FRAGMENT_SHADER,
|
||||
type GlassRippleQuality,
|
||||
} from '@/rendering/glass/glassRippleDynamics'
|
||||
|
||||
class FakeVector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0,
|
||||
) {}
|
||||
|
||||
copy(value: FakeVector2) {
|
||||
return this.set(value.x, value.y)
|
||||
}
|
||||
|
||||
set(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRenderTarget {
|
||||
static instances: FakeRenderTarget[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
height = 1
|
||||
readonly setSize = vi.fn((width: number, height: number) => {
|
||||
this.width = width
|
||||
this.height = height
|
||||
})
|
||||
readonly texture: Record<string, unknown>
|
||||
width = 1
|
||||
|
||||
constructor(
|
||||
_width: number,
|
||||
_height: number,
|
||||
readonly options: Record<string, unknown>,
|
||||
) {
|
||||
this.texture = {
|
||||
format: options.format,
|
||||
magFilter: options.magFilter,
|
||||
minFilter: options.minFilter,
|
||||
type: options.type,
|
||||
wrapS: options.wrapS,
|
||||
wrapT: options.wrapT,
|
||||
}
|
||||
FakeRenderTarget.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShaderMaterial {
|
||||
static instances: FakeShaderMaterial[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
readonly fragmentShader: string
|
||||
readonly uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
|
||||
this.fragmentShader = options.fragmentShader
|
||||
this.uniforms = options.uniforms
|
||||
FakeShaderMaterial.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeScene {
|
||||
readonly children: FakeMesh[] = []
|
||||
|
||||
add(mesh: FakeMesh) {
|
||||
this.children.push(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMesh {
|
||||
frustumCulled = true
|
||||
|
||||
constructor(
|
||||
readonly geometry: unknown,
|
||||
readonly material: FakeShaderMaterial,
|
||||
) {}
|
||||
}
|
||||
|
||||
interface RenderSnapshot {
|
||||
direction: { x: number; y: number }
|
||||
energyDecay: number
|
||||
heightDecay: number
|
||||
impulse: number
|
||||
impulseCenter: { x: number; y: number }
|
||||
impulseOffset: number
|
||||
impulseSigma: number
|
||||
impulseSpeed: number
|
||||
reset: number
|
||||
step: number
|
||||
target: FakeRenderTarget | null
|
||||
velocityDecay: number
|
||||
}
|
||||
|
||||
function createRippleHarness(
|
||||
quality: GlassRippleQuality = 'balanced',
|
||||
compileAsync = vi.fn().mockResolvedValue(undefined),
|
||||
supportsHalfFloatTarget = true,
|
||||
) {
|
||||
let currentTarget: FakeRenderTarget | null = null
|
||||
const snapshots: RenderSnapshot[] = []
|
||||
const renderer = {
|
||||
compileAsync,
|
||||
extensions: {
|
||||
has: vi.fn(() => supportsHalfFloatTarget),
|
||||
},
|
||||
getRenderTarget: vi.fn(() => currentTarget),
|
||||
render: vi.fn((scene: FakeScene) => {
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
snapshots.push({
|
||||
direction: {
|
||||
x: (uniforms.uImpulseDirection.value as FakeVector2).x,
|
||||
y: (uniforms.uImpulseDirection.value as FakeVector2).y,
|
||||
},
|
||||
energyDecay: uniforms.uEnergyDecay.value as number,
|
||||
heightDecay: uniforms.uHeightDecay.value as number,
|
||||
impulse: uniforms.uImpulse.value as number,
|
||||
impulseCenter: {
|
||||
x: (uniforms.uImpulseCenter.value as FakeVector2).x,
|
||||
y: (uniforms.uImpulseCenter.value as FakeVector2).y,
|
||||
},
|
||||
impulseOffset: uniforms.uImpulseOffset.value as number,
|
||||
impulseSigma: uniforms.uImpulseSigma.value as number,
|
||||
impulseSpeed: uniforms.uImpulseSpeed.value as number,
|
||||
reset: uniforms.uReset.value as number,
|
||||
step: uniforms.uStep.value as number,
|
||||
target: currentTarget,
|
||||
velocityDecay: uniforms.uVelocityDecay.value as number,
|
||||
})
|
||||
}),
|
||||
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
|
||||
currentTarget = target
|
||||
}),
|
||||
setScissorTest: vi.fn(),
|
||||
}
|
||||
const three = {
|
||||
ClampToEdgeWrapping: 1001,
|
||||
HalfFloatType: 1005,
|
||||
LinearFilter: 1002,
|
||||
Mesh: FakeMesh,
|
||||
RGBAFormat: 1003,
|
||||
Scene: FakeScene,
|
||||
ShaderMaterial: FakeShaderMaterial,
|
||||
UnsignedByteType: 1004,
|
||||
Vector2: FakeVector2,
|
||||
WebGLRenderTarget: FakeRenderTarget,
|
||||
} as unknown as typeof import('three')
|
||||
|
||||
return {
|
||||
create: () =>
|
||||
createGlassRippleDynamics({
|
||||
camera: {} as never,
|
||||
geometry: {} as never,
|
||||
quality,
|
||||
renderer: renderer as never,
|
||||
three,
|
||||
viewportHeight: 800,
|
||||
viewportWidth: 1200,
|
||||
}),
|
||||
renderer,
|
||||
snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRenderTarget.instances = []
|
||||
FakeShaderMaterial.instances = []
|
||||
})
|
||||
|
||||
describe('glass ripple dynamics', () => {
|
||||
it('uses one bounded half-float ping-pong field when the renderer supports it', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
|
||||
expect(FakeRenderTarget.instances).toHaveLength(2)
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[192, 128],
|
||||
[192, 128],
|
||||
])
|
||||
for (const target of FakeRenderTarget.instances) {
|
||||
expect(target.options).toMatchObject({
|
||||
depthBuffer: false,
|
||||
format: 1003,
|
||||
magFilter: 1002,
|
||||
minFilter: 1002,
|
||||
stencilBuffer: false,
|
||||
type: 1005,
|
||||
wrapS: 1001,
|
||||
wrapT: 1001,
|
||||
})
|
||||
expect(target.texture.generateMipmaps).toBe(false)
|
||||
}
|
||||
expect(harness.renderer.compileAsync).toHaveBeenCalledOnce()
|
||||
expect(harness.snapshots).toHaveLength(2)
|
||||
expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(new Set(harness.snapshots.map(snapshot => snapshot.target))).toEqual(new Set(FakeRenderTarget.instances))
|
||||
expect(dynamics.texture).toBeNull()
|
||||
expect(dynamics.texelSize.x).toBeCloseTo(1 / 192)
|
||||
expect(dynamics.texelSize.y).toBeCloseTo(1 / 128)
|
||||
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the documented neutral encoding, stencil weights and bounded impulse kernel', () => {
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('vec4(0.5, 0.5, 0.0, 1.0)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('sampleValue.b < (1.0 / 255.0)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('cardinal1 * 0.72 + cardinal2 * 0.28')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain(
|
||||
'cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12',
|
||||
)
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalRadius = length(vec2(along * 0.72, across * 1.24))')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float centerRelease = smoothstep(0.0, 0.55, normalizedRadius)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float annularCore = normalizedRadius * core')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain(
|
||||
'float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse',
|
||||
)
|
||||
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('0.58 * core')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalImpulse = clamp(')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('mix(radialImpulse, directionalImpulse')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('impulse * mix(0.52, 0.82, speedResponse)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('uHeightDecay + impulse')
|
||||
})
|
||||
|
||||
it('resizes in viewport space, clears the field and releases every owned resource', async () => {
|
||||
const harness = createRippleHarness('high')
|
||||
const dynamics = await harness.create()
|
||||
harness.snapshots.length = 0
|
||||
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.4, y: 0.6 },
|
||||
speed: 0.8,
|
||||
timestamp: 100,
|
||||
})
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(dynamics.texture).not.toBeNull()
|
||||
|
||||
dynamics.resize(1600, 900)
|
||||
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[400, 225],
|
||||
[400, 225],
|
||||
])
|
||||
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(dynamics.texture).toBeNull()
|
||||
expect(dynamics.texelSize.x).toBeCloseTo(1 / 400)
|
||||
expect(dynamics.texelSize.y).toBeCloseTo(1 / 225)
|
||||
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to an 8-bit field when half-float color targets are unavailable', async () => {
|
||||
const harness = createRippleHarness('balanced', vi.fn().mockResolvedValue(undefined), false)
|
||||
const dynamics = await harness.create()
|
||||
|
||||
expect(FakeRenderTarget.instances.every(target => target.options.type === 1004)).toBe(true)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('clears flow-zero feedback on the next frame and then stops all GPU work', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
dynamics.setParameters(50, 0)
|
||||
dynamics.inject({
|
||||
direction: { x: 0.8, y: 0.2 },
|
||||
point: { x: 0.35, y: 0.65 },
|
||||
speed: 0.7,
|
||||
timestamp: 100,
|
||||
})
|
||||
harness.snapshots.length = 0
|
||||
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(harness.snapshots).toHaveLength(1)
|
||||
expect(dynamics.texture).not.toBeNull()
|
||||
expect(dynamics.step(133.334)).toBe(false)
|
||||
expect(harness.snapshots).toHaveLength(3)
|
||||
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(dynamics.texture).toBeNull()
|
||||
|
||||
expect(dynamics.step(150)).toBe(false)
|
||||
expect(harness.snapshots).toHaveLength(3)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('caps propagation at two substeps while applying decay over the full elapsed time', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
dynamics.setParameters(50, 50)
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.5, y: 0.5 },
|
||||
speed: 0.5,
|
||||
timestamp: 100,
|
||||
})
|
||||
dynamics.step(116.667)
|
||||
harness.snapshots.length = 0
|
||||
|
||||
expect(dynamics.step(166.667)).toBe(true)
|
||||
|
||||
expect(harness.snapshots).toHaveLength(2)
|
||||
const velocityHalfLife = 145
|
||||
const expectedSubstepDecay = 2 ** (-25 / velocityHalfLife)
|
||||
expect(harness.snapshots.every(snapshot => snapshot.step === 1)).toBe(true)
|
||||
expect(
|
||||
harness.snapshots.every(snapshot => Math.abs(snapshot.velocityDecay - expectedSubstepDecay) < 0.000001),
|
||||
).toBe(true)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('integrates directions while retaining the latest point and maximum impulse within one frame', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
harness.snapshots.length = 0
|
||||
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.25, y: 0.35 },
|
||||
speed: 1,
|
||||
timestamp: 100,
|
||||
})
|
||||
dynamics.inject({
|
||||
direction: { x: 0, y: 1 },
|
||||
point: { x: 0.7, y: 0.8 },
|
||||
speed: 0.2,
|
||||
timestamp: 104,
|
||||
})
|
||||
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(harness.snapshots).toHaveLength(1)
|
||||
expect(harness.snapshots[0].direction.x).toBeCloseTo(Math.SQRT1_2)
|
||||
expect(harness.snapshots[0].direction.y).toBeCloseTo(Math.SQRT1_2)
|
||||
expect(harness.snapshots[0].impulseCenter).toEqual({ x: 0.7, y: 0.8 })
|
||||
expect(harness.snapshots[0].impulse).toBeCloseTo(0.8)
|
||||
expect(harness.snapshots[0].impulseOffset).toBe(28)
|
||||
expect(harness.snapshots[0].impulseSigma).toBeCloseTo(75.6)
|
||||
expect(harness.snapshots[0].impulseSpeed).toBe(1)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('keeps the ripple footprint stable across quality levels', async () => {
|
||||
const balancedHarness = createRippleHarness('balanced')
|
||||
const balancedDynamics = await balancedHarness.create()
|
||||
const highHarness = createRippleHarness('high')
|
||||
const highDynamics = await highHarness.create()
|
||||
const interaction = {
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.5, y: 0.5 },
|
||||
speed: 0.5,
|
||||
timestamp: 100,
|
||||
}
|
||||
balancedHarness.snapshots.length = 0
|
||||
highHarness.snapshots.length = 0
|
||||
|
||||
balancedDynamics.setParameters(75, 50)
|
||||
highDynamics.setParameters(75, 50)
|
||||
balancedDynamics.inject(interaction)
|
||||
highDynamics.inject(interaction)
|
||||
balancedDynamics.step(116.667)
|
||||
highDynamics.step(116.667)
|
||||
|
||||
expect(balancedHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
|
||||
expect(highHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
|
||||
balancedDynamics.dispose()
|
||||
highDynamics.dispose()
|
||||
})
|
||||
|
||||
it('disposes partially created resources when shader compilation fails', async () => {
|
||||
const compileAsync = vi.fn().mockRejectedValue(new Error('compile failed'))
|
||||
const harness = createRippleHarness('balanced', compileAsync)
|
||||
|
||||
await expect(harness.create()).rejects.toThrow('compile failed')
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
335
src/rendering/glass/glassFluidDynamics.ts
Normal file
335
src/rendering/glass/glassFluidDynamics.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type {
|
||||
BufferGeometry,
|
||||
IUniform,
|
||||
OrthographicCamera,
|
||||
Texture,
|
||||
Vector2,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
|
||||
type ThreeModule = typeof import('three')
|
||||
|
||||
interface GlassFluidFieldUniforms 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 CreateGlassFluidDynamicsOptions {
|
||||
camera: OrthographicCamera
|
||||
geometry: BufferGeometry
|
||||
pointer: Vector2
|
||||
renderer: WebGLRenderer
|
||||
three: ThreeModule
|
||||
velocity: Vector2
|
||||
}
|
||||
|
||||
export interface GlassFluidDynamics {
|
||||
/** 清除当前输入包络;下一帧会把时序场收敛到中性值。 */
|
||||
clearInput(): void
|
||||
/** 释放 fluid 私有 shader 和两个 ping-pong target。 */
|
||||
dispose(): void
|
||||
/** 清除当前帧注入,避免非输入绘制重复写入同一能量。 */
|
||||
finishFrame(): void
|
||||
/** 调整 fluid field;尺寸只来自主 renderer 已提交的 buffer。 */
|
||||
resize(bufferWidth: number, bufferHeight: number, viewportWidth: number, viewportHeight: number): void
|
||||
/** 更新当前帧的衰减与注入参数,不自行调度动画。 */
|
||||
setFrameParameters(decay: number, injection: number): void
|
||||
/** 推进一次 field 并返回主材质应采样的最新纹理。 */
|
||||
step(): Texture
|
||||
}
|
||||
|
||||
export const GLASS_FLUID_DYNAMIC_RANGE_SCALE = 0.4
|
||||
export const GLASS_FLUID_DYNAMIC_RANGE_DENSITY = 1 / GLASS_FLUID_DYNAMIC_RANGE_SCALE ** 2
|
||||
const GLASS_FLUID_BUFFER_SCALE = 0.25
|
||||
|
||||
const GLASS_FLUID_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = position.xy * 0.5 + 0.5;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const GLASS_FLUID_FIELD_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 * GLASS_FLUID_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 * GLASS_FLUID_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);
|
||||
}
|
||||
`
|
||||
|
||||
/** fluid 主材质的全局临时量;由共享 shader 在原位置逐字拼装。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SETUP = ` 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 = ${GLASS_FLUID_DYNAMIC_RANGE_SCALE.toFixed(2)};
|
||||
const float dynamicRangeDensity = ${GLASS_FLUID_DYNAMIC_RANGE_DENSITY.toFixed(3)};`
|
||||
|
||||
/** fluid 的 trail 与高质量 temporal field 响应。 */
|
||||
export const GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD = ` 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;
|
||||
}`
|
||||
|
||||
/** 单个 surface 内的 fluid 指针、方向、wake 与能量形态。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_SHAPE = ` 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);`
|
||||
|
||||
/** 单个 surface 内的 fluid 高光与焦散响应。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_OPTICS = ` 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;`
|
||||
|
||||
/** fluid 对共享 dynamicRefraction 的贡献;静态透镜和 ripple 响应仍由主材质合成。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION = ` 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;`
|
||||
|
||||
/** 创建仅由高质量 fluid 模式持有的时序位移场。 */
|
||||
export function createGlassFluidDynamics(options: CreateGlassFluidDynamicsOptions): GlassFluidDynamics {
|
||||
const { camera, geometry, pointer, renderer, three, velocity } = options
|
||||
let disposed = false
|
||||
const createTarget = () =>
|
||||
new three.WebGLRenderTarget(1, 1, {
|
||||
depthBuffer: false,
|
||||
magFilter: three.LinearFilter,
|
||||
minFilter: three.LinearFilter,
|
||||
stencilBuffer: false,
|
||||
})
|
||||
let readTarget: WebGLRenderTarget = createTarget()
|
||||
let writeTarget: WebGLRenderTarget = createTarget()
|
||||
const uniforms: GlassFluidFieldUniforms = {
|
||||
uDecay: { value: 1 },
|
||||
uInjection: { value: 0 },
|
||||
uPointer: { value: pointer },
|
||||
uPrevious: { value: null },
|
||||
uTexelSize: { value: new three.Vector2(1, 1) },
|
||||
uVelocity: { value: velocity },
|
||||
uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) },
|
||||
}
|
||||
const material = new three.ShaderMaterial({
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragmentShader: GLASS_FLUID_FIELD_FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
vertexShader: GLASS_FLUID_VERTEX_SHADER,
|
||||
})
|
||||
const scene = new three.Scene()
|
||||
const mesh = new three.Mesh(geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
|
||||
return {
|
||||
clearInput() {
|
||||
uniforms.uDecay.value = 0
|
||||
uniforms.uInjection.value = 0
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
},
|
||||
finishFrame() {
|
||||
uniforms.uInjection.value = 0
|
||||
},
|
||||
resize(bufferWidth, bufferHeight, viewportWidth, viewportHeight) {
|
||||
if (disposed) return
|
||||
const width = Math.max(96, Math.round(bufferWidth * GLASS_FLUID_BUFFER_SCALE))
|
||||
const height = Math.max(96, Math.round(bufferHeight * GLASS_FLUID_BUFFER_SCALE))
|
||||
if (readTarget.width !== width || readTarget.height !== height) {
|
||||
readTarget.setSize(width, height)
|
||||
writeTarget.setSize(width, height)
|
||||
}
|
||||
uniforms.uTexelSize.value.set(1 / width, 1 / height)
|
||||
uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
|
||||
},
|
||||
setFrameParameters(decay, injection) {
|
||||
uniforms.uDecay.value = decay
|
||||
uniforms.uInjection.value = injection
|
||||
},
|
||||
step() {
|
||||
if (disposed) return readTarget.texture
|
||||
renderer.setScissorTest(false)
|
||||
uniforms.uPrevious.value = readTarget.texture
|
||||
renderer.setRenderTarget(writeTarget)
|
||||
renderer.render(scene, camera)
|
||||
renderer.setRenderTarget(null)
|
||||
const previousReadTarget = readTarget
|
||||
readTarget = writeTarget
|
||||
writeTarget = previousReadTarget
|
||||
|
||||
return readTarget.texture
|
||||
},
|
||||
}
|
||||
}
|
||||
480
src/rendering/glass/glassRippleDynamics.ts
Normal file
480
src/rendering/glass/glassRippleDynamics.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import type {
|
||||
BufferGeometry,
|
||||
IUniform,
|
||||
OrthographicCamera,
|
||||
Texture,
|
||||
Vector2,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
import type { GlassOpticalQuality } from '@/utils/glassOptics'
|
||||
|
||||
type ThreeModule = typeof import('three')
|
||||
|
||||
export type GlassRippleQuality = Exclude<GlassOpticalQuality, 'css'>
|
||||
|
||||
interface GlassRippleUniforms extends Record<string, IUniform> {
|
||||
uEnergyDecay: IUniform<number>
|
||||
uHeightDecay: IUniform<number>
|
||||
uImpulse: IUniform<number>
|
||||
uImpulseCenter: IUniform<Vector2>
|
||||
uImpulseDirection: IUniform<Vector2>
|
||||
uImpulseOffset: IUniform<number>
|
||||
uImpulseSigma: IUniform<number>
|
||||
uImpulseSpeed: IUniform<number>
|
||||
uPrevious: IUniform<Texture | null>
|
||||
uPropagation: IUniform<number>
|
||||
uQuality: IUniform<number>
|
||||
uReset: IUniform<number>
|
||||
uRestoring: IUniform<number>
|
||||
uStep: IUniform<number>
|
||||
uTexelSize: IUniform<Vector2>
|
||||
uVelocityDecay: IUniform<number>
|
||||
uViewportSize: IUniform<Vector2>
|
||||
}
|
||||
|
||||
export interface GlassRippleInteraction {
|
||||
/** CSS viewport 中归一化后的输入位置,Y 轴以 WebGL 底部为原点。 */
|
||||
point: { x: number; y: number }
|
||||
/** 归一化后的指针移动方向。 */
|
||||
direction: { x: number; y: number }
|
||||
/** 现有 renderer 归一化后的速度强度,范围 0 到 1。 */
|
||||
speed: number
|
||||
/** 与 performance timeline 一致的事件时间。 */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GlassRippleDynamics {
|
||||
/** 释放波场及其 GPU 资源。 */
|
||||
dispose(): void
|
||||
/** 将输入合并到下一次 GPU step,不为每个事件立即绘制。 */
|
||||
inject(interaction: GlassRippleInteraction): void
|
||||
/** 当前可供主材质采样的波场纹理;空场返回 null。 */
|
||||
readonly texture: Texture | null
|
||||
/** 当前波场单 texel 的 UV 尺寸,供主材质计算高度梯度。 */
|
||||
readonly texelSize: Vector2
|
||||
/** 更新共享动态参数,不重建 GPU 资源。 */
|
||||
setParameters(translationStrength: number, flowStrength: number): void
|
||||
/** 调整 viewport-space 波场;尺寸变化会恢复为空场。 */
|
||||
resize(viewportWidth: number, viewportHeight: number): void
|
||||
/** 立即清空两个 ping-pong target,并停止 CPU 生命周期。 */
|
||||
reset(): void
|
||||
/** 推进一步波场;返回 false 表示已提交清场并停止。 */
|
||||
step(timestamp: number): boolean
|
||||
}
|
||||
|
||||
interface CreateGlassRippleDynamicsOptions {
|
||||
camera: OrthographicCamera
|
||||
geometry: BufferGeometry
|
||||
quality: GlassRippleQuality
|
||||
renderer: WebGLRenderer
|
||||
three: ThreeModule
|
||||
viewportHeight: number
|
||||
viewportWidth: number
|
||||
}
|
||||
|
||||
const RIPPLE_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = position.xy * 0.5 + 0.5;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const RIPPLE_FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPrevious;
|
||||
uniform vec2 uTexelSize;
|
||||
uniform vec2 uViewportSize;
|
||||
uniform vec2 uImpulseCenter;
|
||||
uniform vec2 uImpulseDirection;
|
||||
uniform float uImpulse;
|
||||
uniform float uImpulseOffset;
|
||||
uniform float uImpulseSigma;
|
||||
uniform float uImpulseSpeed;
|
||||
uniform float uPropagation;
|
||||
uniform float uRestoring;
|
||||
uniform float uVelocityDecay;
|
||||
uniform float uHeightDecay;
|
||||
uniform float uEnergyDecay;
|
||||
uniform float uStep;
|
||||
uniform float uQuality;
|
||||
uniform float uReset;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 decodeState(vec4 sampleValue) {
|
||||
if (sampleValue.b < (1.0 / 255.0)) return vec3(0.0);
|
||||
|
||||
return vec3(sampleValue.rg * 2.0 - 1.0, sampleValue.b);
|
||||
}
|
||||
|
||||
float sampleHeight(vec2 offset) {
|
||||
return decodeState(texture2D(uPrevious, clamp(vUv + offset, vec2(0.0), vec2(1.0)))).x;
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (uReset > 0.5) {
|
||||
gl_FragColor = vec4(0.5, 0.5, 0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 previous = decodeState(texture2D(uPrevious, vUv));
|
||||
float h = previous.x;
|
||||
float velocity = previous.y;
|
||||
float energy = previous.z;
|
||||
float cardinal1 = (
|
||||
sampleHeight(vec2(uTexelSize.x, 0.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, 0.0)) +
|
||||
sampleHeight(vec2(0.0, uTexelSize.y)) +
|
||||
sampleHeight(vec2(0.0, -uTexelSize.y))
|
||||
) * 0.25;
|
||||
float cardinal2 = (
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, 0.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, 0.0)) +
|
||||
sampleHeight(vec2(0.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(0.0, -uTexelSize.y * 2.0))
|
||||
) * 0.25;
|
||||
float diagonal1 = (
|
||||
sampleHeight(vec2(uTexelSize.x, uTexelSize.y)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, uTexelSize.y)) +
|
||||
sampleHeight(vec2(uTexelSize.x, -uTexelSize.y)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, -uTexelSize.y))
|
||||
) * 0.25;
|
||||
float diagonal2 = (
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, -uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, -uTexelSize.y * 2.0))
|
||||
) * 0.25;
|
||||
float balancedMean = cardinal1 * 0.72 + cardinal2 * 0.28;
|
||||
float highMean = cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12;
|
||||
float curvature = mix(balancedMean, highMean, uQuality) - h;
|
||||
|
||||
float speedResponse = smoothstep(0.0, 1.0, uImpulseSpeed);
|
||||
vec2 shiftedCenter = uImpulseCenter +
|
||||
uImpulseDirection * uImpulseOffset * mix(0.35, 1.0, speedResponse) / max(uViewportSize, vec2(1.0));
|
||||
vec2 impulseDelta = (vUv - shiftedCenter) * uViewportSize;
|
||||
float directionLength = length(uImpulseDirection);
|
||||
vec2 flowDirection = directionLength > 0.0001 ? uImpulseDirection / directionLength : vec2(0.0, 1.0);
|
||||
vec2 flowPerpendicular = vec2(-flowDirection.y, flowDirection.x);
|
||||
float along = dot(impulseDelta, flowDirection);
|
||||
float across = dot(impulseDelta, flowPerpendicular);
|
||||
float directionalRadius = length(vec2(along * 0.72, across * 1.24));
|
||||
float directionality = step(0.0001, directionLength) * mix(0.32, 0.72, speedResponse);
|
||||
float radius = mix(length(impulseDelta), directionalRadius, directionality);
|
||||
float sigma = max(uImpulseSigma * mix(0.86, 1.05, speedResponse), 1.0);
|
||||
float normalizedRadius = radius / sigma;
|
||||
float core = exp(-0.5 * pow(normalizedRadius, 2.0));
|
||||
float ring = exp(-0.5 * pow((radius - 1.6 * sigma) / (0.55 * sigma), 2.0));
|
||||
float centerRelease = smoothstep(0.0, 0.55, normalizedRadius);
|
||||
float annularCore = normalizedRadius * core;
|
||||
float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse;
|
||||
float wakeEnvelope = exp(-0.5 * (
|
||||
pow(along / (1.25 * sigma), 2.0) +
|
||||
pow(across / (0.72 * sigma), 2.0)
|
||||
));
|
||||
float directionalImpulse = clamp((-along / sigma) * wakeEnvelope * uImpulse * 0.9, -0.62, 0.62);
|
||||
float impulse = clamp(
|
||||
mix(radialImpulse, directionalImpulse, directionality),
|
||||
-0.62,
|
||||
0.62
|
||||
);
|
||||
|
||||
velocity = clamp(
|
||||
(
|
||||
velocity +
|
||||
curvature * uPropagation * uStep -
|
||||
h * uRestoring * uStep +
|
||||
impulse * mix(0.52, 0.82, speedResponse)
|
||||
) * uVelocityDecay,
|
||||
-1.0,
|
||||
1.0
|
||||
);
|
||||
h = clamp((h + velocity * uStep) * uHeightDecay, -1.0, 1.0);
|
||||
energy = clamp(max(max(energy * uEnergyDecay, abs(h)), abs(impulse)), 0.0, 1.0);
|
||||
gl_FragColor = vec4(h * 0.5 + 0.5, velocity * 0.5 + 0.5, energy, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRESHNESS_MS = 40
|
||||
const ENVELOPE_THRESHOLD = 0.006
|
||||
const MAX_STEP_MS = 16.667
|
||||
const MIN_STEP_MS = 4
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function mix(start: number, end: number, progress: number) {
|
||||
return start + (end - start) * progress
|
||||
}
|
||||
|
||||
/** 创建仅由单个 renderer context 持有的 viewport-space 水漾场。 */
|
||||
export async function createGlassRippleDynamics(
|
||||
options: CreateGlassRippleDynamicsOptions,
|
||||
): Promise<GlassRippleDynamics> {
|
||||
const { camera, geometry, renderer, three } = options
|
||||
const quality = options.quality
|
||||
let viewportWidth = Math.max(1, options.viewportWidth)
|
||||
let viewportHeight = Math.max(1, options.viewportHeight)
|
||||
let translation = 0.5
|
||||
let flow = 0.5
|
||||
let energyAtInput = 0
|
||||
let lastInputAt = Number.NEGATIVE_INFINITY
|
||||
let deadlineAt = Number.NEGATIVE_INFINITY
|
||||
let lastStepAt = 0
|
||||
let pendingImpulse = 0
|
||||
let pendingSpeed = 0
|
||||
let impulseCenter = { x: 0.5, y: 0.5 }
|
||||
let impulseDirection = { x: 0, y: 1 }
|
||||
let pendingDirection = { x: 0, y: 0 }
|
||||
let clearOnNextFrame = false
|
||||
let fieldActive = false
|
||||
let disposed = false
|
||||
const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType
|
||||
|
||||
const createTarget = () => {
|
||||
const target = new three.WebGLRenderTarget(1, 1, {
|
||||
depthBuffer: false,
|
||||
format: three.RGBAFormat,
|
||||
magFilter: three.LinearFilter,
|
||||
minFilter: three.LinearFilter,
|
||||
stencilBuffer: false,
|
||||
type: targetType,
|
||||
wrapS: three.ClampToEdgeWrapping,
|
||||
wrapT: three.ClampToEdgeWrapping,
|
||||
})
|
||||
target.texture.generateMipmaps = false
|
||||
|
||||
return target
|
||||
}
|
||||
let readTarget = createTarget()
|
||||
let writeTarget = createTarget()
|
||||
const uniforms: GlassRippleUniforms = {
|
||||
uEnergyDecay: { value: 1 },
|
||||
uHeightDecay: { value: 1 },
|
||||
uImpulse: { value: 0 },
|
||||
uImpulseCenter: { value: new three.Vector2(0.5, 0.5) },
|
||||
uImpulseDirection: { value: new three.Vector2(0, 1) },
|
||||
uImpulseOffset: { value: 0 },
|
||||
uImpulseSigma: { value: 24 },
|
||||
uImpulseSpeed: { value: 0 },
|
||||
uPrevious: { value: null },
|
||||
uPropagation: { value: 0.18 },
|
||||
uQuality: { value: quality === 'high' ? 1 : 0 },
|
||||
uReset: { value: 1 },
|
||||
uRestoring: { value: quality === 'high' ? 0.028 : 0.035 },
|
||||
uStep: { value: 1 },
|
||||
uTexelSize: { value: new three.Vector2(1, 1) },
|
||||
uVelocityDecay: { value: 1 },
|
||||
uViewportSize: { value: new three.Vector2(viewportWidth, viewportHeight) },
|
||||
}
|
||||
const material = new three.ShaderMaterial({
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragmentShader: RIPPLE_FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
vertexShader: RIPPLE_VERTEX_SHADER,
|
||||
})
|
||||
const scene = new three.Scene()
|
||||
const mesh = new three.Mesh(geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
|
||||
const renderTarget = (target: WebGLRenderTarget) => {
|
||||
const previousTarget = renderer.getRenderTarget()
|
||||
|
||||
try {
|
||||
renderer.setScissorTest(false)
|
||||
renderer.setRenderTarget(target)
|
||||
renderer.render(scene, camera)
|
||||
} finally {
|
||||
renderer.setRenderTarget(previousTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const writeNeutralTargets = () => {
|
||||
uniforms.uReset.value = 1
|
||||
uniforms.uPrevious.value = null
|
||||
renderTarget(readTarget)
|
||||
renderTarget(writeTarget)
|
||||
uniforms.uReset.value = 0
|
||||
}
|
||||
|
||||
const getTargetSize = (width: number, height: number) => {
|
||||
const scale =
|
||||
quality === 'high'
|
||||
? Math.min(1, Math.max(0.25, 192 / width, 128 / height))
|
||||
: Math.min(1, Math.max(0.16, 128 / width, 96 / height))
|
||||
|
||||
return {
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
const resize = (width: number, height: number) => {
|
||||
if (disposed) return false
|
||||
const nextViewportWidth = Math.max(1, width)
|
||||
const nextViewportHeight = Math.max(1, height)
|
||||
const target = getTargetSize(nextViewportWidth, nextViewportHeight)
|
||||
const viewportChanged = viewportWidth !== nextViewportWidth || viewportHeight !== nextViewportHeight
|
||||
const targetChanged = readTarget.width !== target.width || readTarget.height !== target.height
|
||||
if (!viewportChanged && !targetChanged) return false
|
||||
|
||||
viewportWidth = nextViewportWidth
|
||||
viewportHeight = nextViewportHeight
|
||||
if (targetChanged) {
|
||||
readTarget.setSize(target.width, target.height)
|
||||
writeTarget.setSize(target.width, target.height)
|
||||
}
|
||||
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
|
||||
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
||||
reset()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getVelocityHalfLife = () => (quality === 'high' ? mix(90, 280, flow) : mix(70, 220, flow))
|
||||
|
||||
const getDeadlineDuration = () => FRESHNESS_MS + (quality === 'high' ? mix(220, 920, flow) : mix(160, 680, flow))
|
||||
|
||||
const settleEnvelope = (timestamp: number) => {
|
||||
if (!Number.isFinite(lastInputAt)) return 0
|
||||
const freshReleaseAge = Math.max(0, timestamp - lastInputAt - FRESHNESS_MS)
|
||||
const deadlineTaper = clamp01((deadlineAt - timestamp) / FRESHNESS_MS)
|
||||
|
||||
return energyAtInput * 2 ** (-freshReleaseAge / getVelocityHalfLife()) * deadlineTaper
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (disposed) return
|
||||
writeNeutralTargets()
|
||||
energyAtInput = 0
|
||||
lastInputAt = Number.NEGATIVE_INFINITY
|
||||
deadlineAt = Number.NEGATIVE_INFINITY
|
||||
lastStepAt = 0
|
||||
pendingImpulse = 0
|
||||
pendingSpeed = 0
|
||||
pendingDirection = { x: 0, y: 0 }
|
||||
impulseDirection = { x: 0, y: 1 }
|
||||
clearOnNextFrame = false
|
||||
fieldActive = false
|
||||
}
|
||||
|
||||
try {
|
||||
const initializedByResize = resize(viewportWidth, viewportHeight)
|
||||
await renderer.compileAsync(scene, camera)
|
||||
if (disposed) throw new Error('Ripple resources were disposed during compilation')
|
||||
if (!initializedByResize) reset()
|
||||
} catch (error) {
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
},
|
||||
inject(interaction) {
|
||||
if (disposed) return
|
||||
const timestamp = interaction.timestamp
|
||||
const previousEnvelope = settleEnvelope(timestamp)
|
||||
const inputAmplitude = Math.min(0.8, Math.max(0.22, 0.22 + clamp01(interaction.speed) * 0.58))
|
||||
energyAtInput = Math.max(previousEnvelope, inputAmplitude)
|
||||
lastInputAt = timestamp
|
||||
deadlineAt = timestamp + getDeadlineDuration()
|
||||
pendingImpulse = Math.max(pendingImpulse, inputAmplitude)
|
||||
pendingSpeed = Math.max(pendingSpeed, clamp01(interaction.speed))
|
||||
impulseCenter = { x: clamp01(interaction.point.x), y: clamp01(interaction.point.y) }
|
||||
const directionLength = Math.hypot(interaction.direction.x, interaction.direction.y)
|
||||
if (directionLength > 0.0001) {
|
||||
pendingDirection.x += interaction.direction.x / directionLength
|
||||
pendingDirection.y += interaction.direction.y / directionLength
|
||||
}
|
||||
fieldActive = true
|
||||
clearOnNextFrame = false
|
||||
},
|
||||
get texture() {
|
||||
return fieldActive && !disposed ? readTarget.texture : null
|
||||
},
|
||||
get texelSize() {
|
||||
return uniforms.uTexelSize.value
|
||||
},
|
||||
setParameters(translationStrength, flowStrength) {
|
||||
translation = clamp01(translationStrength / 100)
|
||||
flow = clamp01(flowStrength / 100)
|
||||
},
|
||||
resize,
|
||||
reset,
|
||||
step(timestamp) {
|
||||
if (disposed || !fieldActive) return false
|
||||
if (clearOnNextFrame || timestamp >= deadlineAt || settleEnvelope(timestamp) < ENVELOPE_THRESHOLD) {
|
||||
reset()
|
||||
return false
|
||||
}
|
||||
|
||||
const elapsed = lastStepAt > 0 ? Math.max(0, timestamp - lastStepAt) : MAX_STEP_MS
|
||||
const simulatedElapsed = Math.min(MAX_STEP_MS * 2, Math.max(MIN_STEP_MS, elapsed))
|
||||
const substeps = simulatedElapsed > MAX_STEP_MS ? 2 : 1
|
||||
const stepMs = Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, simulatedElapsed / substeps))
|
||||
const decayStepMs = elapsed / substeps
|
||||
const velocityHalfLife = getVelocityHalfLife()
|
||||
const heightHalfLife = velocityHalfLife * 0.82
|
||||
const energyHalfLife = velocityHalfLife * 0.72
|
||||
const targetCssPerTexel = Math.sqrt(
|
||||
(viewportWidth / Math.max(readTarget.width, 1)) * (viewportHeight / Math.max(readTarget.height, 1)),
|
||||
)
|
||||
const referenceCssPerTexel = quality === 'high' ? 4 : 6.25
|
||||
const basePropagation = quality === 'high' ? mix(0.11, 0.16, translation) : mix(0.12, 0.18, translation)
|
||||
|
||||
if (pendingImpulse > 0) {
|
||||
const directionLength = Math.hypot(pendingDirection.x, pendingDirection.y)
|
||||
impulseDirection =
|
||||
directionLength > 0.0001
|
||||
? { x: pendingDirection.x / directionLength, y: pendingDirection.y / directionLength }
|
||||
: { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
uniforms.uImpulseCenter.value.set(impulseCenter.x, impulseCenter.y)
|
||||
uniforms.uImpulseDirection.value.set(impulseDirection.x, impulseDirection.y)
|
||||
uniforms.uImpulseOffset.value = 56 * translation
|
||||
uniforms.uImpulseSpeed.value = pendingSpeed
|
||||
// 质量档把额外预算用于场分辨率和衰减细节;输入范围保持稳定,避免高质量改变动态效果的空间语义。
|
||||
uniforms.uImpulseSigma.value = mix(54, 97.2, translation)
|
||||
uniforms.uPropagation.value = Math.min(
|
||||
0.18,
|
||||
Math.max(0.08, basePropagation * (referenceCssPerTexel / targetCssPerTexel) ** 2),
|
||||
)
|
||||
uniforms.uRestoring.value = quality === 'high' ? 0.028 : 0.035
|
||||
uniforms.uStep.value = stepMs / MAX_STEP_MS
|
||||
uniforms.uVelocityDecay.value = 2 ** (-decayStepMs / velocityHalfLife)
|
||||
uniforms.uHeightDecay.value = 2 ** (-decayStepMs / heightHalfLife)
|
||||
uniforms.uEnergyDecay.value = 2 ** (-decayStepMs / energyHalfLife)
|
||||
|
||||
for (let index = 0; index < substeps; index += 1) {
|
||||
uniforms.uPrevious.value = readTarget.texture
|
||||
uniforms.uImpulse.value = index === 0 ? pendingImpulse : 0
|
||||
renderTarget(writeTarget)
|
||||
const previousReadTarget = readTarget
|
||||
readTarget = writeTarget
|
||||
writeTarget = previousReadTarget
|
||||
}
|
||||
pendingImpulse = 0
|
||||
pendingSpeed = 0
|
||||
pendingDirection = { x: 0, y: 0 }
|
||||
lastStepAt = timestamp
|
||||
if (flow <= 0) clearOnNextFrame = true
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user