Skip to content

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

元素包裝器 wrapper

包起來的內容可以燒起來、也可以凍起來,火焰與冰塊都貼著輪廓長。(ノ>ω<)ノ

技術關鍵字

名稱描述
Babylon.js3D 引擎
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
SDF (Signed Distance Field)有號距離場,用數學函式描述形狀輪廓,常用於 Shader 中繪製平滑圖形
Stable FluidsJos Stam 提出的即時流體解法,平流、外力、壓力投影輪流跑,數值再大也不會爆掉
Noise比 Math.random 更自然隨機效果,常用於地形、雲朵、材質等
Voronoi細胞噪聲,把平面切成一格格不規則細胞,冰塊裡的裂紋就是這樣來的
Fresnel視線越貼近表面反射越強的現象,冰塊、玻璃邊緣發亮、正面透明就是它
粒子系統產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果
Bloom泛光,把畫面裡超亮的部分抽出來模糊後加回去,發光體周圍才會暈開
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊

使用範例

基本用法

element 設成 fireice 就會套上效果。切回 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;
}

v0.89.3