mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 01:36:45 +08:00
feat(glass): add selectable dynamics modes (#639)
This commit is contained in:
@@ -1150,6 +1150,7 @@ onUnmounted(() => {
|
|||||||
v-if="shouldRenderGlassOpticalLayer"
|
v-if="shouldRenderGlassOpticalLayer"
|
||||||
:appearance="effectiveGlassSettings.glassAppearance"
|
:appearance="effectiveGlassSettings.glassAppearance"
|
||||||
:deformation-strength="opticalDeformationStrength"
|
:deformation-strength="opticalDeformationStrength"
|
||||||
|
:dynamics-mode="effectiveGlassSettings.glassDynamicsMode"
|
||||||
:flow-strength="opticalFlowStrength"
|
:flow-strength="opticalFlowStrength"
|
||||||
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
||||||
:reflection-strength="opticalReflectionStrength"
|
:reflection-strength="opticalReflectionStrength"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
previewGlassSettings,
|
previewGlassSettings,
|
||||||
useThemeCustomizer,
|
useThemeCustomizer,
|
||||||
type ThemeCustomizerGlassAppearance,
|
type ThemeCustomizerGlassAppearance,
|
||||||
|
type ThemeCustomizerGlassDynamicsMode,
|
||||||
type ThemeCustomizerGlassQuality,
|
type ThemeCustomizerGlassQuality,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,7 @@ const usesMobilePresentation = useGlassMobilePresentation()
|
|||||||
const { settings } = useThemeCustomizer()
|
const { settings } = useThemeCustomizer()
|
||||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||||
|
const draftDynamicsMode = ref<ThemeCustomizerGlassDynamicsMode>(settings.value.glassDynamicsMode)
|
||||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||||
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||||
@@ -53,7 +55,8 @@ const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
|||||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
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 availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||||
@@ -75,6 +78,7 @@ watch(
|
|||||||
if (value) {
|
if (value) {
|
||||||
draftAppearance.value = settings.value.glassAppearance
|
draftAppearance.value = settings.value.glassAppearance
|
||||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||||
|
draftDynamicsMode.value = settings.value.glassDynamicsMode
|
||||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||||
draftPreset.value = settings.value.glassPreset
|
draftPreset.value = settings.value.glassPreset
|
||||||
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||||
@@ -120,6 +124,18 @@ const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
|||||||
const visiblePresetOptions = computed(() =>
|
const visiblePresetOptions = computed(() =>
|
||||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
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) {
|
function updateAppearance(value: unknown) {
|
||||||
@@ -138,11 +154,21 @@ function updateQuality(value: unknown) {
|
|||||||
applyPreset(activePreset.value)
|
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() {
|
function previewDraftParameters() {
|
||||||
previewGlassSettings({
|
previewGlassSettings({
|
||||||
glassAppearance: draftAppearance.value,
|
glassAppearance: draftAppearance.value,
|
||||||
glassDeformationStrength: draftDeformationStrength.value,
|
glassDeformationStrength: draftDeformationStrength.value,
|
||||||
|
glassDynamicsMode: draftDynamicsMode.value,
|
||||||
glassFlowStrength: draftFlowStrength.value,
|
glassFlowStrength: draftFlowStrength.value,
|
||||||
glassPreset: draftPreset.value,
|
glassPreset: draftPreset.value,
|
||||||
glassPresetOverrides: draftPresetOverrides.value,
|
glassPresetOverrides: draftPresetOverrides.value,
|
||||||
@@ -258,6 +284,7 @@ async function saveSettings() {
|
|||||||
previewGlassSettings({
|
previewGlassSettings({
|
||||||
glassAppearance: draftAppearance.value,
|
glassAppearance: draftAppearance.value,
|
||||||
glassDeformationStrength: draftDeformationStrength.value,
|
glassDeformationStrength: draftDeformationStrength.value,
|
||||||
|
glassDynamicsMode: draftDynamicsMode.value,
|
||||||
glassFlowStrength: draftFlowStrength.value,
|
glassFlowStrength: draftFlowStrength.value,
|
||||||
glassPreset: draftPreset.value,
|
glassPreset: draftPreset.value,
|
||||||
glassPresetOverrides: draftPresetOverrides.value,
|
glassPresetOverrides: draftPresetOverrides.value,
|
||||||
@@ -317,6 +344,7 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
{{ t(option.label) }}
|
{{ t(option.label) }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VBtnToggle>
|
</VBtnToggle>
|
||||||
|
<p class="glass-settings-dialog__hint">{{ t('theme.glassAppearanceHint') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -362,6 +390,29 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
{{ t(option.label) }}
|
{{ t(option.label) }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VBtnToggle>
|
</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>
|
||||||
|
|
||||||
<section class="glass-settings-dialog__tuning">
|
<section class="glass-settings-dialog__tuning">
|
||||||
@@ -580,6 +631,7 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-settings-dialog__appearance,
|
.glass-settings-dialog__appearance,
|
||||||
|
.glass-settings-dialog__dynamics-mode,
|
||||||
.glass-settings-dialog__quality,
|
.glass-settings-dialog__quality,
|
||||||
.glass-settings-dialog__preset {
|
.glass-settings-dialog__preset {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -597,6 +649,11 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 {
|
.glass-settings-dialog__quality {
|
||||||
block-size: 42px !important;
|
block-size: 42px !important;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -615,6 +672,7 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.glass-settings-dialog__appearance-option,
|
.glass-settings-dialog__appearance-option,
|
||||||
|
.glass-settings-dialog__dynamics-mode-option,
|
||||||
.glass-settings-dialog__quality-option,
|
.glass-settings-dialog__quality-option,
|
||||||
.glass-settings-dialog__preset-option {
|
.glass-settings-dialog__preset-option {
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
@@ -630,11 +688,16 @@ onScopeDispose(cancelGlassPreview)
|
|||||||
block-size: 32px !important;
|
block-size: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glass-settings-dialog__dynamics-mode-option {
|
||||||
|
block-size: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.glass-settings-dialog__preset-option {
|
.glass-settings-dialog__preset-option {
|
||||||
block-size: 32px !important;
|
block-size: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
.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__quality-option:deep(.v-btn--active),
|
||||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
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>',
|
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||||
}
|
}
|
||||||
const toggleStub = {
|
const toggleStub = {
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
name: 'VBtnToggle',
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
value: {
|
value: {
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 50,
|
glassDeformationStrength: 50,
|
||||||
|
glassDynamicsMode: 'fluid',
|
||||||
glassFlowStrength: 50,
|
glassFlowStrength: 50,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {},
|
glassPresetOverrides: {},
|
||||||
@@ -78,6 +81,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
mocks.display.smAndDown.value = false
|
mocks.display.smAndDown.value = false
|
||||||
mocks.settings.value.glassAppearance = 'clear'
|
mocks.settings.value.glassAppearance = 'clear'
|
||||||
mocks.settings.value.glassDeformationStrength = 50
|
mocks.settings.value.glassDeformationStrength = 50
|
||||||
|
mocks.settings.value.glassDynamicsMode = 'fluid'
|
||||||
mocks.settings.value.glassFlowStrength = 50
|
mocks.settings.value.glassFlowStrength = 50
|
||||||
mocks.settings.value.glassPreset = 'natural'
|
mocks.settings.value.glassPreset = 'natural'
|
||||||
mocks.settings.value.glassPresetOverrides = {}
|
mocks.settings.value.glassPresetOverrides = {}
|
||||||
@@ -174,6 +178,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||||
glassAppearance: 'frosted',
|
glassAppearance: 'frosted',
|
||||||
glassDeformationStrength: 79,
|
glassDeformationStrength: 79,
|
||||||
|
glassDynamicsMode: 'fluid',
|
||||||
glassFlowStrength: 77,
|
glassFlowStrength: 77,
|
||||||
glassPreset: 'liquid',
|
glassPreset: 'liquid',
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
@@ -218,6 +223,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 50,
|
glassDeformationStrength: 50,
|
||||||
|
glassDynamicsMode: 'fluid',
|
||||||
glassFlowStrength: 50,
|
glassFlowStrength: 50,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
@@ -255,6 +261,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
|
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__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 () => {
|
it('keeps the selected preset highlighted and records its combination override', async () => {
|
||||||
@@ -335,6 +342,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 69,
|
glassDeformationStrength: 69,
|
||||||
|
glassDynamicsMode: 'fluid',
|
||||||
glassFlowStrength: 62,
|
glassFlowStrength: 62,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
@@ -364,6 +372,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
VCardActions: slotStub,
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
|
VBtnToggle: toggleStub,
|
||||||
VDialog: dialogStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
@@ -374,6 +383,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
const sliders = wrapper.findAll('.slider-stub')
|
const sliders = wrapper.findAll('.slider-stub')
|
||||||
|
|
||||||
expect(sliders).toHaveLength(6)
|
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([
|
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
|
||||||
'theme.glassTransparencyStrength',
|
'theme.glassTransparencyStrength',
|
||||||
'theme.glassTransmissionStrength',
|
'theme.glassTransmissionStrength',
|
||||||
@@ -386,6 +396,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
|
|
||||||
it('restores motion tuning when a mobile presentation returns to desktop', async () => {
|
it('restores motion tuning when a mobile presentation returns to desktop', async () => {
|
||||||
mocks.usesMobilePresentation!.value = true
|
mocks.usesMobilePresentation!.value = true
|
||||||
|
mocks.settings.value.glassDynamicsMode = 'ripple'
|
||||||
mocks.settings.value.glassQuality = 'high'
|
mocks.settings.value.glassQuality = 'high'
|
||||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||||
global: {
|
global: {
|
||||||
@@ -393,6 +404,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
VCard: slotStub,
|
VCard: slotStub,
|
||||||
VCardActions: slotStub,
|
VCardActions: slotStub,
|
||||||
VCardText: slotStub,
|
VCardText: slotStub,
|
||||||
|
VBtnToggle: toggleStub,
|
||||||
VDialog: dialogStub,
|
VDialog: dialogStub,
|
||||||
VDialogCloseBtn: true,
|
VDialogCloseBtn: true,
|
||||||
VSlider: sliderStub,
|
VSlider: sliderStub,
|
||||||
@@ -407,6 +419,7 @@ describe('GlassSettingsDialog', () => {
|
|||||||
'theme.glassReflectionStrength',
|
'theme.glassReflectionStrength',
|
||||||
])
|
])
|
||||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
|
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()).toContain('theme.glassMaterialStrengthHint')
|
||||||
expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint')
|
expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint')
|
||||||
expect(wrapper.text()).toContain('theme.glassQualityMobileHint')
|
expect(wrapper.text()).toContain('theme.glassQualityMobileHint')
|
||||||
@@ -416,7 +429,63 @@ describe('GlassSettingsDialog', () => {
|
|||||||
|
|
||||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
||||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(true)
|
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.glassMaterialStrengthHint')
|
||||||
expect(wrapper.text()).toContain('theme.glassOpticalStrengthHint')
|
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">
|
<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 { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
|
||||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +23,8 @@ const props = defineProps<{
|
|||||||
deformationStrength: number
|
deformationStrength: number
|
||||||
/** 用户选择的轨迹、尾波与惯性强度。 */
|
/** 用户选择的轨迹、尾波与惯性强度。 */
|
||||||
flowStrength: number
|
flowStrength: number
|
||||||
|
/** 用户保存的动态效果模式;能力降级不会回写该选择。 */
|
||||||
|
dynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||||
/** 当前光学质量;标准档不会挂载该组件。 */
|
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||||
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||||
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
||||||
@@ -74,7 +81,16 @@ const emit = defineEmits<{
|
|||||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
const usesMobilePresentation = useGlassMobilePresentation()
|
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 interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
|
||||||
const pagePresentationMotion = usePagePresentationMotion()
|
const pagePresentationMotion = usePagePresentationMotion()
|
||||||
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
||||||
@@ -84,6 +100,7 @@ const fixedRenderer = useGlassOpticalRenderer({
|
|||||||
canvas: fixedCanvas,
|
canvas: fixedCanvas,
|
||||||
deformationStrength: () => props.deformationStrength,
|
deformationStrength: () => props.deformationStrength,
|
||||||
dynamicsActive,
|
dynamicsActive,
|
||||||
|
dynamicsMode: effectiveDynamicsMode,
|
||||||
flowStrength: () => props.flowStrength,
|
flowStrength: () => props.flowStrength,
|
||||||
interactionSource,
|
interactionSource,
|
||||||
quality: () => props.quality,
|
quality: () => props.quality,
|
||||||
@@ -109,6 +126,7 @@ const scrollRenderer = useGlassOpticalRenderer({
|
|||||||
canvas: scrollCanvas,
|
canvas: scrollCanvas,
|
||||||
deformationStrength: () => props.deformationStrength,
|
deformationStrength: () => props.deformationStrength,
|
||||||
dynamicsActive,
|
dynamicsActive,
|
||||||
|
dynamicsMode: effectiveDynamicsMode,
|
||||||
flowStrength: () => props.flowStrength,
|
flowStrength: () => props.flowStrength,
|
||||||
interactionSource,
|
interactionSource,
|
||||||
pageMotion: pagePresentationMotion.reader,
|
pageMotion: pagePresentationMotion.reader,
|
||||||
@@ -130,16 +148,45 @@ const scrollRenderer = useGlassOpticalRenderer({
|
|||||||
syncDocumentState: false,
|
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')
|
const rendererState = ref<GlassRendererState>('loading')
|
||||||
|
|
||||||
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
||||||
const state: GlassRendererState = states.every(value => value === 'ready')
|
const allReady = states.every(value => value === 'ready')
|
||||||
? 'ready'
|
const anyFallback = states.some(value => value === 'fallback')
|
||||||
: states.some(value => value === 'loading')
|
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'
|
? 'loading'
|
||||||
: 'fallback'
|
: compositeFailureLatched.value
|
||||||
|
? 'fallback'
|
||||||
|
: allReady
|
||||||
|
? 'ready'
|
||||||
|
: 'loading'
|
||||||
|
|
||||||
setGlassRendererState(rendererState, state)
|
setGlassRendererState(rendererState, state)
|
||||||
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
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 = ''
|
let lastPreparedAcknowledgement = ''
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
const url = props.pendingWallpaperUrl
|
const url = props.pendingWallpaperUrl
|
||||||
@@ -287,6 +339,8 @@ watchEffect(() => {
|
|||||||
|
|
||||||
onScopeDispose(() => {
|
onScopeDispose(() => {
|
||||||
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||||
|
delete document.documentElement.dataset.glassDynamicsMode
|
||||||
|
delete document.documentElement.dataset.glassDynamicsEffectiveMode
|
||||||
setGlassRendererState(rendererState, 'fallback')
|
setGlassRendererState(rendererState, 'fallback')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ const hasAppModeCustomization = computed(() => {
|
|||||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||||
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
|
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
|
||||||
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
|
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
|
||||||
|
settings.value.glassDynamicsMode !== defaultAppModeGlassSettings.glassDynamicsMode ||
|
||||||
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
|
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
|
||||||
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
|
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
|
||||||
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
|
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||||
|
|
||||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||||
|
const rendererInitialStates = vi.hoisted(() => [] as string[])
|
||||||
const rendererResults = vi.hoisted(
|
const rendererResults = vi.hoisted(
|
||||||
() =>
|
() =>
|
||||||
[] as Array<{
|
[] as Array<{
|
||||||
@@ -19,6 +20,7 @@ const rendererResults = vi.hoisted(
|
|||||||
preparedWallpaperRevision: { value: number }
|
preparedWallpaperRevision: { value: number }
|
||||||
preparedWallpaperUrl: { value: string }
|
preparedWallpaperUrl: { value: string }
|
||||||
renderedFrames: { value: number }
|
renderedFrames: { value: number }
|
||||||
|
retryAfterFailure: ReturnType<typeof vi.fn>
|
||||||
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
|
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
|
||||||
state: { value: string }
|
state: { value: string }
|
||||||
}>,
|
}>,
|
||||||
@@ -61,7 +63,12 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
|||||||
preparedWallpaperRevision: ref(0),
|
preparedWallpaperRevision: ref(0),
|
||||||
preparedWallpaperUrl: ref(''),
|
preparedWallpaperUrl: ref(''),
|
||||||
renderedFrames: ref(0),
|
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) => {
|
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
|
||||||
return (
|
return (
|
||||||
result.state.value === 'ready' &&
|
result.state.value === 'ready' &&
|
||||||
@@ -114,6 +121,7 @@ vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mobilePresentationState.current!.value = false
|
mobilePresentationState.current!.value = false
|
||||||
|
rendererInitialStates.length = 0
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -126,6 +134,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
props: {
|
props: {
|
||||||
appearance: 'clear',
|
appearance: 'clear',
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'fluid',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
previousWallpaperUrl: '',
|
previousWallpaperUrl: '',
|
||||||
quality: 'balanced',
|
quality: 'balanced',
|
||||||
@@ -146,6 +155,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||||
expect(rendererCalls.map(options => options.surfaceSpace)).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.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.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).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')
|
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 () => {
|
it('keeps both material contexts while disabling dynamics on mobile presentations', async () => {
|
||||||
rendererCalls.length = 0
|
rendererCalls.length = 0
|
||||||
rendererResults.length = 0
|
rendererResults.length = 0
|
||||||
@@ -169,6 +210,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
props: {
|
props: {
|
||||||
appearance: 'frosted',
|
appearance: 'frosted',
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'ripple',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
previousWallpaperUrl: '',
|
previousWallpaperUrl: '',
|
||||||
quality: 'high',
|
quality: 'high',
|
||||||
@@ -186,11 +228,15 @@ describe('GlassOpticalLayer', () => {
|
|||||||
|
|
||||||
expect(wrapper.findAll('canvas')).toHaveLength(2)
|
expect(wrapper.findAll('canvas')).toHaveLength(2)
|
||||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
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
|
mobilePresentationState.current!.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,6 +256,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
appearance: 'frosted',
|
appearance: 'frosted',
|
||||||
activateWallpaperRevision: 0,
|
activateWallpaperRevision: 0,
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'fluid',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
pendingWallpaperRevision: 7,
|
pendingWallpaperRevision: 7,
|
||||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||||
@@ -267,6 +314,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
props: {
|
props: {
|
||||||
appearance: 'frosted',
|
appearance: 'frosted',
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'fluid',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
pendingWallpaperRevision: 10,
|
pendingWallpaperRevision: 10,
|
||||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||||
@@ -322,6 +370,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
appearance: 'frosted',
|
appearance: 'frosted',
|
||||||
activateWallpaperRevision: 8,
|
activateWallpaperRevision: 8,
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'ripple',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
pendingWallpaperRevision: 8,
|
pendingWallpaperRevision: 8,
|
||||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||||
@@ -348,13 +397,34 @@ describe('GlassOpticalLayer', () => {
|
|||||||
scrollRenderer.state.value = 'fallback'
|
scrollRenderer.state.value = 'fallback'
|
||||||
await nextTick()
|
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(requestFrame).not.toHaveBeenCalled()
|
||||||
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||||
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
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'
|
scrollRenderer.state.value = 'ready'
|
||||||
await nextTick()
|
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()
|
expect(requestFrame).toHaveBeenCalledOnce()
|
||||||
;(activationCallback as FrameRequestCallback | null)?.(640)
|
;(activationCallback as FrameRequestCallback | null)?.(640)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -365,6 +435,93 @@ describe('GlassOpticalLayer', () => {
|
|||||||
wrapper.unmount()
|
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([
|
it.each([
|
||||||
[
|
[
|
||||||
'returns false',
|
'returns false',
|
||||||
@@ -392,6 +549,7 @@ describe('GlassOpticalLayer', () => {
|
|||||||
appearance: 'frosted',
|
appearance: 'frosted',
|
||||||
activateWallpaperRevision: 9,
|
activateWallpaperRevision: 9,
|
||||||
deformationStrength: 50,
|
deformationStrength: 50,
|
||||||
|
dynamicsMode: 'fluid',
|
||||||
flowStrength: 50,
|
flowStrength: 50,
|
||||||
pendingWallpaperRevision: 9,
|
pendingWallpaperRevision: 9,
|
||||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { effectScope, nextTick, ref } from 'vue'
|
import { effectScope, nextTick, ref } from 'vue'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
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(() => ({
|
const wallpaperToneMocks = vi.hoisted(() => ({
|
||||||
load: vi.fn(),
|
load: vi.fn(),
|
||||||
@@ -294,6 +294,30 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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', () => {
|
it('detects a target surface added directly', () => {
|
||||||
const surface = document.createElement('section')
|
const surface = document.createElement('section')
|
||||||
surface.dataset.glassOpticalSurface = ''
|
surface.dataset.glassOpticalSurface = ''
|
||||||
@@ -2269,6 +2293,90 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 () => {
|
it('coalesces visibility, focus, and pageshow into one renderer resume', async () => {
|
||||||
const three = await import('three')
|
const three = await import('three')
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -2556,6 +2664,442 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 () => {
|
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
|
||||||
stubMediaPreferences({ coarsePointer: true })
|
stubMediaPreferences({ coarsePointer: true })
|
||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||||
@@ -2794,6 +3338,67 @@ describe('glass optical surface discovery', () => {
|
|||||||
scope.stop()
|
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 () => {
|
it('ignores scroll intent and movement that cannot move a managed glass surface', async () => {
|
||||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||||
@@ -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 uTranslationStrength')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
|
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('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 sharedWaveDensity = mix(2.81, 1.63')
|
||||||
expect(scene.children[0].material.fragmentShader).toContain(
|
expect(scene.children[0].material.fragmentShader).toContain(
|
||||||
'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)',
|
'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)',
|
||||||
@@ -3750,4 +4364,122 @@ describe('glass optical surface discovery', () => {
|
|||||||
expect(callbacks.size).toBe(0)
|
expect(callbacks.size).toBe(0)
|
||||||
scope.stop()
|
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.glassAppearance).toBe('clear')
|
||||||
expect(settings.glassDeformationStrength).toBe(48)
|
expect(settings.glassDeformationStrength).toBe(48)
|
||||||
|
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||||
expect(settings.glassFlowStrength).toBe(48)
|
expect(settings.glassFlowStrength).toBe(48)
|
||||||
expect(settings.glassPreset).toBe('natural')
|
expect(settings.glassPreset).toBe('natural')
|
||||||
expect(settings.glassPresetOverrides).toEqual({})
|
expect(settings.glassPresetOverrides).toEqual({})
|
||||||
@@ -71,6 +72,14 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
||||||
expect(customizer.isCustomized.value).toBe(false)
|
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()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -78,6 +87,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(getDefaultGlassCustomizerSettings('css')).toEqual({
|
expect(getDefaultGlassCustomizerSettings('css')).toEqual({
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 48,
|
glassDeformationStrength: 48,
|
||||||
|
glassDynamicsMode: 'ripple',
|
||||||
glassFlowStrength: 48,
|
glassFlowStrength: 48,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {},
|
glassPresetOverrides: {},
|
||||||
@@ -101,15 +111,27 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance)
|
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', () => {
|
it('falls back when stored glass settings are invalid', () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
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()
|
const settings = readThemeCustomizerSettings()
|
||||||
|
|
||||||
expect(settings.glassAppearance).toBe('clear')
|
expect(settings.glassAppearance).toBe('clear')
|
||||||
|
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||||
expect(settings.glassPreset).toBe('natural')
|
expect(settings.glassPreset).toBe('natural')
|
||||||
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
||||||
expect(settings.glassQuality).toBe('balanced')
|
expect(settings.glassQuality).toBe('balanced')
|
||||||
@@ -184,9 +206,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
||||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70)
|
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70)
|
||||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||||
0.48,
|
|
||||||
)
|
|
||||||
expect(Number(document.body.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.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
|
||||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
|
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', () => {
|
it('previews glass settings without persisting them', () => {
|
||||||
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||||
|
|
||||||
previewGlassSettings({ glassAppearance: 'tinted' })
|
previewGlassSettings({ glassAppearance: 'tinted', glassDynamicsMode: 'ripple' })
|
||||||
|
|
||||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||||
expect(readThemeCustomizerSettings().glassAppearance).toBe('clear')
|
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)
|
expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -208,6 +230,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
previewGlassSettings({
|
previewGlassSettings({
|
||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 74,
|
glassDeformationStrength: 74,
|
||||||
|
glassDynamicsMode: 'ripple',
|
||||||
glassFlowStrength: 63,
|
glassFlowStrength: 63,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
@@ -232,6 +255,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 74,
|
glassDeformationStrength: 74,
|
||||||
|
glassDynamicsMode: 'ripple',
|
||||||
glassFlowStrength: 63,
|
glassFlowStrength: 63,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
@@ -288,6 +312,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
previewGlassSettings({
|
previewGlassSettings({
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 90,
|
glassDeformationStrength: 90,
|
||||||
|
glassDynamicsMode: 'off',
|
||||||
glassFlowStrength: 88,
|
glassFlowStrength: 88,
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
'clear:balanced:natural': {
|
'clear:balanced:natural': {
|
||||||
@@ -307,6 +332,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(effective.value).toMatchObject({
|
expect(effective.value).toMatchObject({
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: 90,
|
glassDeformationStrength: 90,
|
||||||
|
glassDynamicsMode: 'off',
|
||||||
glassFlowStrength: 88,
|
glassFlowStrength: 88,
|
||||||
glassReflectionStrength: 12,
|
glassReflectionStrength: 12,
|
||||||
glassTransmissionStrength: 92,
|
glassTransmissionStrength: 92,
|
||||||
@@ -320,6 +346,7 @@ describe('useThemeCustomizer glass settings', () => {
|
|||||||
expect(effective.value).toMatchObject({
|
expect(effective.value).toMatchObject({
|
||||||
glassAppearance: 'tinted',
|
glassAppearance: 'tinted',
|
||||||
glassDeformationStrength: 42,
|
glassDeformationStrength: 42,
|
||||||
|
glassDynamicsMode: 'ripple',
|
||||||
glassFlowStrength: 44,
|
glassFlowStrength: 44,
|
||||||
glassPresetOverrides: {
|
glassPresetOverrides: {
|
||||||
'tinted:balanced:natural': {
|
'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 () => {
|
it('applies the same preset for a new material and quality while preset-managed', async () => {
|
||||||
persistPartialThemeCustomizerSettings({
|
persistPartialThemeCustomizerSettings({
|
||||||
glassAppearance: 'clear',
|
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 ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||||
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
|
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||||
|
/** 玻璃动态效果的持久化选择;关闭模式仍保留用户配置的动态参数。 */
|
||||||
|
export type ThemeCustomizerGlassDynamicsMode = 'fluid' | 'ripple' | 'off'
|
||||||
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
|
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
|
||||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||||
@@ -79,6 +81,8 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
|
|||||||
export interface ThemeCustomizerSettings {
|
export interface ThemeCustomizerSettings {
|
||||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||||
glassAppearance: ThemeCustomizerGlassAppearance
|
glassAppearance: ThemeCustomizerGlassAppearance
|
||||||
|
/** 玻璃动态效果模式,与六参数预设矩阵保持独立。 */
|
||||||
|
glassDynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||||
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
||||||
glassDeformationStrength: number
|
glassDeformationStrength: number
|
||||||
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
||||||
@@ -117,6 +121,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
|||||||
|
|
||||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||||
|
const validGlassDynamicsModes: ThemeCustomizerGlassDynamicsMode[] = ['fluid', 'ripple', 'off']
|
||||||
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
||||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||||
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
||||||
@@ -138,6 +143,7 @@ type DefaultGlassCustomizerSettings = Pick<
|
|||||||
ThemeCustomizerSettings,
|
ThemeCustomizerSettings,
|
||||||
| 'glassAppearance'
|
| 'glassAppearance'
|
||||||
| 'glassDeformationStrength'
|
| 'glassDeformationStrength'
|
||||||
|
| 'glassDynamicsMode'
|
||||||
| 'glassFlowStrength'
|
| 'glassFlowStrength'
|
||||||
| 'glassPreset'
|
| 'glassPreset'
|
||||||
| 'glassPresetOverrides'
|
| 'glassPresetOverrides'
|
||||||
@@ -176,6 +182,7 @@ export function getDefaultGlassCustomizerSettings(
|
|||||||
return {
|
return {
|
||||||
glassAppearance: 'clear',
|
glassAppearance: 'clear',
|
||||||
glassDeformationStrength: glassParameters.deformation,
|
glassDeformationStrength: glassParameters.deformation,
|
||||||
|
glassDynamicsMode: 'ripple',
|
||||||
glassFlowStrength: glassParameters.flow,
|
glassFlowStrength: glassParameters.flow,
|
||||||
glassPreset: 'natural',
|
glassPreset: 'natural',
|
||||||
glassPresetOverrides: {},
|
glassPresetOverrides: {},
|
||||||
@@ -287,6 +294,9 @@ function normalizeThemeCustomizerSettings(
|
|||||||
settings.glassMotionStrength,
|
settings.glassMotionStrength,
|
||||||
fallback.glassDeformationStrength,
|
fallback.glassDeformationStrength,
|
||||||
),
|
),
|
||||||
|
glassDynamicsMode: validGlassDynamicsModes.includes(settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||||
|
? (settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||||
|
: fallback.glassDynamicsMode,
|
||||||
glassFlowStrength: normalizeMigratedGlassStrength(
|
glassFlowStrength: normalizeMigratedGlassStrength(
|
||||||
settings.glassFlowStrength,
|
settings.glassFlowStrength,
|
||||||
settings.glassMotionStrength,
|
settings.glassMotionStrength,
|
||||||
@@ -368,6 +378,7 @@ type ThemeCustomizerGlassSettings = Pick<
|
|||||||
ThemeCustomizerSettings,
|
ThemeCustomizerSettings,
|
||||||
| 'glassAppearance'
|
| 'glassAppearance'
|
||||||
| 'glassDeformationStrength'
|
| 'glassDeformationStrength'
|
||||||
|
| 'glassDynamicsMode'
|
||||||
| 'glassFlowStrength'
|
| 'glassFlowStrength'
|
||||||
| 'glassPreset'
|
| 'glassPreset'
|
||||||
| 'glassPresetOverrides'
|
| 'glassPresetOverrides'
|
||||||
@@ -382,6 +393,7 @@ const effectiveGlassSettings = computed(() => ({
|
|||||||
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||||
glassDeformationStrength:
|
glassDeformationStrength:
|
||||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||||
|
glassDynamicsMode: glassPreviewState.value?.glassDynamicsMode ?? settingsState.value.glassDynamicsMode,
|
||||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||||
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
||||||
@@ -591,6 +603,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
|
|||||||
glassPreviewState.value = {
|
glassPreviewState.value = {
|
||||||
glassAppearance: previewSettings.glassAppearance,
|
glassAppearance: previewSettings.glassAppearance,
|
||||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||||
|
glassDynamicsMode: previewSettings.glassDynamicsMode,
|
||||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||||
glassPreset: previewSettings.glassPreset,
|
glassPreset: previewSettings.glassPreset,
|
||||||
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
||||||
@@ -645,6 +658,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
return (
|
return (
|
||||||
settings.glassAppearance === defaults.glassAppearance &&
|
settings.glassAppearance === defaults.glassAppearance &&
|
||||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||||
|
settings.glassDynamicsMode === defaults.glassDynamicsMode &&
|
||||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||||
settings.glassPreset === defaults.glassPreset &&
|
settings.glassPreset === defaults.glassPreset &&
|
||||||
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
||||||
@@ -762,6 +776,11 @@ export function useThemeCustomizer() {
|
|||||||
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 切换玻璃动态效果,不改写当前预设归属或六个具体参数。 */
|
||||||
|
function setGlassDynamicsMode(glassDynamicsMode: ThemeCustomizerGlassDynamicsMode) {
|
||||||
|
return updateSettings({ glassDynamicsMode })
|
||||||
|
}
|
||||||
|
|
||||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||||
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
||||||
@@ -901,6 +920,7 @@ export function useThemeCustomizer() {
|
|||||||
resetSettings,
|
resetSettings,
|
||||||
setGlassAppearance,
|
setGlassAppearance,
|
||||||
setGlassDeformationStrength,
|
setGlassDeformationStrength,
|
||||||
|
setGlassDynamicsMode,
|
||||||
setGlassFlowStrength,
|
setGlassFlowStrength,
|
||||||
setGlassPreset,
|
setGlassPreset,
|
||||||
setGlassQuality,
|
setGlassQuality,
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export default {
|
|||||||
glassAppearanceClear: 'Clear',
|
glassAppearanceClear: 'Clear',
|
||||||
glassAppearanceTinted: 'Tinted',
|
glassAppearanceTinted: 'Tinted',
|
||||||
glassAppearanceFrosted: 'Frosted',
|
glassAppearanceFrosted: 'Frosted',
|
||||||
|
glassAppearanceHint:
|
||||||
|
'Clear emphasizes wallpaper detail, Tinted adds color coverage, and Frosted uses blur diffusion for a denser glass surface.',
|
||||||
glassQuality: 'Quality',
|
glassQuality: 'Quality',
|
||||||
glassQualityCss: 'Standard',
|
glassQualityCss: 'Standard',
|
||||||
glassQualityBalanced: 'Balanced',
|
glassQualityBalanced: 'Balanced',
|
||||||
@@ -165,6 +167,14 @@ export default {
|
|||||||
glassPresetNatural: 'Natural',
|
glassPresetNatural: 'Natural',
|
||||||
glassPresetGlide: 'Glide',
|
glassPresetGlide: 'Glide',
|
||||||
glassPresetLiquid: 'Liquid',
|
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',
|
glassMaterialTuning: 'Material',
|
||||||
glassDynamicTuning: 'Motion',
|
glassDynamicTuning: 'Motion',
|
||||||
glassTranslationStrength: 'Sample Translation',
|
glassTranslationStrength: 'Sample Translation',
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export default {
|
|||||||
glassAppearanceClear: '透明',
|
glassAppearanceClear: '透明',
|
||||||
glassAppearanceTinted: '色调',
|
glassAppearanceTinted: '色调',
|
||||||
glassAppearanceFrosted: '磨砂',
|
glassAppearanceFrosted: '磨砂',
|
||||||
|
glassAppearanceHint: '透明突出壁纸纹理,色调增加颜色覆盖,磨砂通过模糊扩散呈现更厚的玻璃质感。',
|
||||||
glassQuality: '质量',
|
glassQuality: '质量',
|
||||||
glassQualityCss: '标准',
|
glassQualityCss: '标准',
|
||||||
glassQualityBalanced: '均衡',
|
glassQualityBalanced: '均衡',
|
||||||
@@ -162,6 +163,14 @@ export default {
|
|||||||
glassPresetNatural: '自然',
|
glassPresetNatural: '自然',
|
||||||
glassPresetGlide: '滑移',
|
glassPresetGlide: '滑移',
|
||||||
glassPresetLiquid: '液态',
|
glassPresetLiquid: '液态',
|
||||||
|
glassPresetHint: '自然均衡克制,滑移强调顺畅移动,液态增强形变与惯性。',
|
||||||
|
glassDynamicsMode: '动态效果',
|
||||||
|
glassDynamicsModeFluid: '流体',
|
||||||
|
glassDynamicsModeRipple: '水漾',
|
||||||
|
glassDynamicsModeOff: '关闭',
|
||||||
|
glassDynamicsModeFluidHint: '跟随指针形成连续的流动与折射反馈。',
|
||||||
|
glassDynamicsModeRippleHint: '指针经过玻璃时产生向相邻表面扩散的水纹。',
|
||||||
|
glassDynamicsModeOffHint: '保留静态材质,不响应指针动态。',
|
||||||
glassMaterialTuning: '材质参数',
|
glassMaterialTuning: '材质参数',
|
||||||
glassDynamicTuning: '动态参数',
|
glassDynamicTuning: '动态参数',
|
||||||
glassTranslationStrength: '采样平移',
|
glassTranslationStrength: '采样平移',
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export default {
|
|||||||
glassAppearanceClear: '透明',
|
glassAppearanceClear: '透明',
|
||||||
glassAppearanceTinted: '色調',
|
glassAppearanceTinted: '色調',
|
||||||
glassAppearanceFrosted: '磨砂',
|
glassAppearanceFrosted: '磨砂',
|
||||||
|
glassAppearanceHint: '透明強調桌布紋理,色調增加色彩覆蓋,磨砂透過模糊擴散呈現更厚實的玻璃質感。',
|
||||||
glassQuality: '品質',
|
glassQuality: '品質',
|
||||||
glassQualityCss: '標準',
|
glassQualityCss: '標準',
|
||||||
glassQualityBalanced: '均衡',
|
glassQualityBalanced: '均衡',
|
||||||
@@ -162,6 +163,14 @@ export default {
|
|||||||
glassPresetNatural: '自然',
|
glassPresetNatural: '自然',
|
||||||
glassPresetGlide: '滑移',
|
glassPresetGlide: '滑移',
|
||||||
glassPresetLiquid: '液態',
|
glassPresetLiquid: '液態',
|
||||||
|
glassPresetHint: '自然均衡克制,滑移強調順暢移動,液態增強形變與慣性。',
|
||||||
|
glassDynamicsMode: '動態效果',
|
||||||
|
glassDynamicsModeFluid: '流體',
|
||||||
|
glassDynamicsModeRipple: '水漾',
|
||||||
|
glassDynamicsModeOff: '關閉',
|
||||||
|
glassDynamicsModeFluidHint: '跟隨指標形成連續的流動與折射回饋。',
|
||||||
|
glassDynamicsModeRippleHint: '指標經過玻璃時產生向相鄰表面擴散的水紋。',
|
||||||
|
glassDynamicsModeOffHint: '保留靜態材質,不回應指標動態。',
|
||||||
glassMaterialTuning: '材質參數',
|
glassMaterialTuning: '材質參數',
|
||||||
glassDynamicTuning: '動態參數',
|
glassDynamicTuning: '動態參數',
|
||||||
glassTranslationStrength: '採樣平移',
|
glassTranslationStrength: '採樣平移',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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