元素包裝器 wrapper
包起來的內容可以燒起來、也可以凍起來,火焰與冰塊都貼著輪廓長。(ノ>ω<)ノ
技術關鍵字
| 名稱 | 描述 |
|---|---|
| Babylon.js | 3D 引擎 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| SDF (Signed Distance Field) | 有號距離場,用數學函式描述形狀輪廓,常用於 Shader 中繪製平滑圖形 |
| Stable Fluids | Jos Stam 提出的即時流體解法,平流、外力、壓力投影輪流跑,數值再大也不會爆掉 |
| Noise | 比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等 |
| Voronoi | 細胞噪聲,把平面切成一格格不規則細胞,冰塊裡的裂紋就是這樣來的 |
| Fresnel | 視線越貼近表面反射越強的現象,冰塊、玻璃邊緣發亮、正面透明就是它 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| Bloom | 泛光,把畫面裡超亮的部分抽出來模糊後加回去,發光體周圍才會暈開 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
使用範例
基本用法
把 element 設成 fire 或 ice 就會套上效果。切回 none 時火會慢慢熄滅,凝固的冰塊則直接碎開,播完才還原。滑鼠掃過或上下捲動畫面,火焰與冰塊周圍的霧氣都會被吹開喔。ᕕ( ゚ ∀。)ᕗ

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
查看範例原始碼
vue
<template>
<div class="w-full flex flex-col items-center gap-12 py-10">
<div class="flex flex-col items-center gap-3">
<div class="flex flex-wrap justify-center gap-3">
<base-btn
v-for="option in optionList"
:key="option.value"
:label="option.label"
:class="{ 'option--active': element === option.value }"
@click="element = option.value"
/>
</div>
<!-- 測試用:複製整頁共用畫布的效能報告,方便回報 -->
<base-btn
v-if="perfReportVisible"
:label="copied ? t('copied') : t('copyPerfReport')"
class="text-xs opacity-70"
@click="copyPerfReport"
/>
</div>
<wrapper-elemental
:ref="wrapperRefList.set"
:element="element"
>
<img
src="/low/profile.webp"
alt=""
class="w-40 border-[0.25rem] rounded-full object-cover"
>
</wrapper-elemental>
<wrapper-elemental
:ref="wrapperRefList.set"
:element="element"
>
<div class="card border rounded-lg p-6">
<div class="text-2xl font-bold">
{{ t('codfish') }}
</div>
<div class="mt-2 max-w-[17rem]">
{{ t('codfishDescription') }}
</div>
</div>
</wrapper-elemental>
</div>
</template>
<script setup lang="ts">
import type { ElementalType } from '../type'
import { useClipboard, useTemplateRefsList } from '@vueuse/core'
import { useData } from 'vitepress'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const data = useData()
const element = ref<ElementalType>('fire')
const optionList = computed(() => [
{ value: 'fire' as const, label: t('fire') },
{ value: 'ice' as const, label: t('ice') },
{ value: 'none' as const, label: t('none') },
])
const wrapperRefList = useTemplateRefsList<
InstanceType<typeof WrapperElemental>
>()
/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
wrapperRefList.value.forEach((wrapperRef) => {
wrapperRef.refresh()
})
})
/** 效能報告按鈕平時隱藏,要量測時改成 true */
const perfReportVisible = false
const { copy, copied } = useClipboard({ legacy: true })
/** 所有元素共用一張畫布,任一個元素拿到的都是整頁的報告 */
function copyPerfReport() {
const report = wrapperRefList.value
.map((wrapperRef) => wrapperRef.getPerfReport())
.find((value) => value !== undefined)
copy(`時間:${new Date().toISOString()}\n\n${report ?? t('noPerfReport')}`)
}
</script>
<style scoped lang="sass">
.card
background: light-dark(#EEE, #333)
.option--active
background-color: light-dark(#DDD, #555)
</style>表單範例
沒填完,按鈕會凍結。(´,,•ω•,,)
查看範例原始碼
vue
<template>
<div class="relative w-full flex justify-center py-24">
<div class="max-w-[20rem] flex flex-col gap-4">
<base-input
v-model="form.nickname"
type="text"
autocomplete="off"
:label="t('暱稱 *')"
:placeholder="t('至少 {count} 個字', { count: MIN_NICKNAME_LENGTH })"
class="w-full"
/>
<div class="mt-3 flex justify-center">
<wrapper-elemental
:element="unfinished ? 'ice' : 'none'"
:transition-duration="800"
>
<base-btn
:label="t('送出')"
@click="handleSubmit"
/>
</wrapper-elemental>
</div>
</div>
<transition name="opacity">
<div
v-if="isSubmitted"
class="absolute inset-0 z-[40] flex flex-col items-center justify-center gap-6 rounded-xl bg-slate-600 bg-opacity-90 text-white"
@click="reset"
>
<span class="text-xl tracking-wide">
{{ t('送出成功!(*´∀`)~♥') }}
</span>
<span class="cursor-pointer text-xs">
{{ t('點一下再來一次') }}
</span>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import WrapperElemental from '../wrapper-elemental.vue'
const { t } = useI18n()
const MIN_NICKNAME_LENGTH = 2
const form = ref({
nickname: '',
})
/** 暱稱字數不足,按鈕就凍在冰塊裡 */
const unfinished = computed(() => form.value.nickname.length < MIN_NICKNAME_LENGTH)
const isSubmitted = ref(false)
function handleSubmit() {
// 冰塊只是畫面,底下的按鈕還點得到,送出前要再檢查一次
if (unfinished.value) {
return
}
isSubmitted.value = true
}
function reset() {
isSubmitted.value = false
form.value = {
nickname: '',
}
}
</script>
<style lang="sass" scoped>
.opacity-enter-active, .opacity-leave-active
transition-duration: 0.4s
.opacity-enter-from, .opacity-leave-to
opacity: 0 !important
</style>原理
整頁共用一張畫布
內容先用 snapdom 拍成貼圖,再算出一張有號距離場,火與冰塊都靠它知道輪廓在哪。
整頁的元素共用一張鋪滿視窗的畫布、一份流體模擬與一套泛光,空氣固定在畫面上。捲動時是元素在空氣裡移動,已經竄起的火與冒出的霧留在原地,像被風吹向後方。
火
熱從輪廓冒出、受浮力往上竄,由 GPU 流體模擬出火焰的形狀,再以體積光線步進沿深度累加 3D 噪聲,畫出有前後層次的火。
真實火焰會週期性地一口一口往上吐(puffing),這裡以營火尺度約 2 Hz 為準。火會遮住內容、讓內容被熱浪扭曲,看起來才在內容前方。
冰
內容先逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊;解除時直接碎成 Voronoi 碎片飛散。
冰面旁的冷空氣變重往下流,霧在冰的底部冒得最多,隨流體往下飄、逐漸散開。
原始碼
API
Props
interface Props {
/**
* 目前附加的元素屬性。
*
* `fire` 從底部點燃,GPU 流體模擬的火焰沿著輪廓往上竄,內容受熱變亮,指標掃過會把火吹歪;
* `ice` 讓內容逐漸轉冷,接著凝成一塊會折射內容的 3D 冰塊。
* 切回 `none` 時火會先熄滅;冰若已凝固就直接碎開,還沒凝固則融化,結束後才還原成原本的 DOM。
*
* @default 'none'
*/
element?: ElementalType;
/**
* 特效強度,影響火勢、粒子數量與冰殼厚度。
*
* 1 為預設外觀,2 已相當誇張。
*
* @default 1
*/
intensity?: number;
/**
* 點燃、熄滅、結凍、融化各自的過渡時間(ms)。
*
* @default 1500
*/
transitionDuration?: number;
}Emits
interface Emits {
/** 舞台完成初始化、開始繪製時觸發 */
ready: [];
/** 過渡結束時觸發,帶著當時的元素屬性;收到 `none` 代表已完全還原 */
settled: [element: ElementalType];
}Methods
interface Expose {
/** 重新擷取內容圖片,內容或主題變更後呼叫 */
refresh: () => Promise<void>;
/**
* 測試用:取得效能報告文字,包含裝置、共用畫布、流體規格、頁面上每個元素的狀態與最近 300 幀的耗時。
* 效果未啟動時回傳 undefined
*/
getPerfReport: () => string | undefined;
}Slots
interface Slots {
/** 要附加元素效果的內容 */
default?: () => unknown;
}