Skip to content

蟲群包裝器 wrapper

包起來的內容全部化為蟲群,滑鼠一揮就散開,手離開後慢慢聚回原位。


鱈魚:「油女一族的秘傳忍術升級啦!ヽ(●´∀`●)ノ」

同事:「你的腦子終於裝滿 Bug 了?(´・ω・`)」

鱈魚:「說好的禮貌呢?ლ(╹ε╹ლ)」

技術關鍵字

名稱描述
DOM to Image將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現
Canvas getImageData取得指定 Canvas 區域的像素資料
Canvas Shader使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難
Curl Noise基於 Noise 的無散度向量場,產生自然流動的粒子運動效果
粒子系統產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果
物理模擬模擬真實世界物理現象,如重力、碰撞、速度等物理效果
向量計算處理方向、加速度、速度等等數學運算
Pointer 事件偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊

使用範例

基本用法

滑鼠靠近時粒子散開,離開後慢慢聚回。

鱈魚
一隻熱愛程式的魚,但是沒有手指可以打鍵盤,更買不到能在水裡用的電腦。(´;ω;`)
查看範例原始碼
vue
<template>
  <div class="example-wrap w-full flex flex-col items-center justify-center gap-10 py-10">
    <wrapper-swarm ref="swarmRef">
      <div class="flex flex-col items-center gap-10">
        <img
          src="/low/profile.webp"
          alt=""
          class="w-60 border rounded-full object-cover"
        >

        <div class="card border rounded p-6">
          <div class="text-center text-xl font-bold">
            {{ t('codfish') }}
          </div>

          <div class="mt-2 max-w-[17rem]">
            {{ t('codfishDescription') }}
          </div>
        </div>
      </div>
    </wrapper-swarm>
  </div>
</template>

<script setup lang="ts">
import { useData } from 'vitepress'
import { useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperSwarm from '../wrapper-swarm.vue'

const { t } = useI18n()
const data = useData()

const swarmRef = useTemplateRef<InstanceType<typeof WrapperSwarm>>('swarmRef')

/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
  swarmRef.value?.refresh()
})
</script>

<style scoped lang="sass">
.card
  background: light-dark(#EEE, #333)
</style>

意願調查表

調查下午要不要喝飲料。♪( ◜ω◝و(و

是否訂飲料

同事:「這選項太過分了喔!ლ(´口`ლ)」

查看範例原始碼
vue
<template>
  <div class="w-full flex justify-center p-6">
    <div class="example-wrap flex flex-col items-start gap-4 px-10">
      <div class="text-xl font-bold">
        {{ t('title') }}
      </div>

      <div class="w-full flex flex-col select-none gap-4 whitespace-nowrap">
        <label class="w-fit flex items-center gap-2 text-lg">
          <input
            v-model="value"
            type="radio"
            value="yes"
            class="size-6"
          >
          {{ t('drinkOption.yes') }}
        </label>

        <wrapper-swarm ref="swarmRef">
          <div class="flex flex-col gap-4">
            <label
              v-for="optionKey in swarmOptionKeyList"
              :key="optionKey"
              class="w-fit flex items-center gap-2 text-lg"
            >
              <!--
                不加 disabled,看起來就是一般選項。
                蟲群舞台本身會蓋住內容並吃掉指標事件,點不到自然選不到。
              -->
              <input
                v-model="value"
                type="radio"
                :value="optionKey"
                class="size-6"
              >
              {{ t(`drinkOption.${optionKey}`) }}
            </label>
          </div>
        </wrapper-swarm>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useData } from 'vitepress'
import { ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WrapperSwarm from '../wrapper-swarm.vue'

const { t } = useI18n()
const data = useData()

const swarmOptionKeyList = ['no', 'onTheHouse', 'later', 'dependsOnBrand']

const value = ref('')

const swarmRef = useTemplateRef('swarmRef')

/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
  swarmRef.value?.refresh()
})
</script>

表單範例

沒填完沒得按。(╯•̀ὤ•́)╯

查看範例原始碼
vue
<template>
  <div class="example-wrap relative w-full flex justify-center py-6">
    <div class="max-w-[16rem] w-full flex flex-col gap-4">
      <base-input
        v-model="form.username"
        :label="t('username')"
        class="w-full"
      />

      <base-input
        v-model="form.password"
        type="password"
        :label="t('password')"
        class="w-full"
      />

      <div class="mt-6 flex flex-col items-center gap-3">
        <!--
          沒填完就開啟蟲群,按鈕化為粒子,滑鼠一靠近就散開。
          蟲群舞台會蓋住內容並吃掉指標事件,不必另外 disabled 也按不到。
        -->
        <wrapper-swarm
          ref="swarmRef"
          :enabled="disabled"
        >
          <base-btn
            :label="t('submit')"
            class="whitespace-nowrap"
            @click="handleSubmit"
          />
        </wrapper-swarm>
      </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('submitted') }}
        </span>

        <span class="cursor-pointer text-xs">
          {{ t('retry') }}
        </span>
      </div>
    </transition>
  </div>
</template>

<script setup lang="ts">
import { useData } from 'vitepress'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseInput from '../../base-input.vue'
import WrapperSwarm from '../wrapper-swarm.vue'

const { t } = useI18n()
const data = useData()

const form = ref({
  username: '',
  password: '',
})
const disabled = computed(() => {
  return form.value.username === '' || form.value.password === ''
})

const isSubmitted = ref(false)
function handleSubmit() {
  if (disabled.value) {
    return
  }
  isSubmitted.value = true
}

function reset() {
  isSubmitted.value = false

  form.value = {
    username: '',
    password: '',
  }
}

const swarmRef = useTemplateRef('swarmRef')

/** 深色模式切換後配色會變,需重新擷取內容 */
watch(() => data.isDark.value, () => {
  swarmRef.value?.refresh()
})
</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>

原理

先把 DOM 拍成照片

snapdom 把插槽內容拍成畫布,getImageData 逐點取樣,不透明像素才生成粒子,原始 DOM 轉透明退居幕後。跟蟲群文字不同,包裝器可能吃進彩色卡片、照片、漸層,每顆粒子還得多存一份來源像素的 RGB。

scatterPadding 讓畫布往四周撐大一圈,粒子才有地方飄。視窗太窄時左右留白會依可用寬度自動裁掉,以免多出水平捲軸。

enabled 切換時畫布與原始 DOM 交叉淡入淡出,畫布等淡出跑完才移除,蟲群不會整片斷電。

CSS 像素與裝置像素

粒子密度要以哪一種像素計算,是這個元件最需要想清楚的一件事,因為同一個「像素」在網頁上有兩種意思。

寫版面用的是 CSS 像素,width: 120px 在哪台螢幕上看起來都差不多大。螢幕上真正發光的顆粒則是裝置像素,兩者的倍率就是 devicePixelRatio。桌機多半是 1,手機常見 2 或 3。倍率 3 代表橫向、縱向各切三份,一個 CSS 像素於是攤在 9 個裝置像素上。

一般螢幕(DPR 1)1 個 CSS 像素內含 1 個裝置像素高解析手機(DPR 3)1 個 CSS 像素內含 9 個裝置像素

兩個方框在畫面上一樣大,物理尺寸也一樣,差別只在切得多細。所以取樣間距選錯單位,成本會直接乘上倍率的平方。

同一塊 4 × 4 CSS 像素大的內容,在像素比 3 的手機上,兩種間距生出來的粒子數差了九倍。

間距 1 CSS 像素16 顆粒子間距 1 裝置像素144 顆粒子

粒子多了九倍,畫面沒有更好看,記憶體與運算卻整個翻上去。因此間距改以 CSS 像素計算,一顆粒子對應一個 CSS 像素,密度只跟版面大小有關,各種螢幕的成本一致。

滿版內容仍可能衝到上百萬顆,所以用 maxParticleCount 當煞車,超標就放大間距重新取樣。

取樣格本身仍對齊裝置像素格,繪製時粒子邊長還會比格距多一個裝置像素,刻意讓相鄰粒子重疊。邊長剛好貼齊格距時,行動裝置 GPU 會依各家驅動的邊界規則規律地漏掉整列,圖案浮現一條條橫縫,多蓋一格就不可能漏。粒子不透明,重疊只讓後畫的蓋掉前一顆,清晰度仍由取樣間距決定。

物理模擬全部丟給 GPU

粒子狀態存在浮點紋理,RGBA 四通道放位置與速度,兩張紋理輪流讀寫,也就是 ping-pong。每幀先跑 physics pass 寫入新狀態,再用 render pass 以 gl_VertexID 查表畫出全部粒子,CPU 全程不用介入。

擾動來自 curl noise,開場先在 GPU 算好一張 256×256 查表紋理,省下每幀重算 FBM 的成本。

讓蟲群像活的

滑鼠靠近時同時給三種力:順揮動方向的風力、curl noise 亂流,以及把粒子推出中央的徑向推力,中間因此浮現空洞。

回歸若走直線只會像整齊方陣,所以離原位越遠的粒子多吃一份低頻噪聲,帶動鄰近粒子同向偏移,形成群聚感。每顆粒子還有專屬隨機因子,摩擦力、力道、回歸速率略有差異,整群才不會同進同出。

原始碼

API

Props

interface Props {
  /**
   * 關閉後回到原本 DOM,不生成任何粒子。
   *
   * 切換時畫布與原始 DOM 交叉淡入淡出,不會瞬間斷電。
   *
   * @default true
   */
  enabled?: boolean;
  /**
   * 粒子取樣間距,單位為 CSS 像素。
   *
   * 1 代表一顆粒子對應版面上一個 CSS 像素,各種螢幕的粒子密度與成本一致。
   * 若改用裝置像素計算,同一塊內容在像素比 3 的手機上會生出九倍粒子,
   * 畫面沒有更好看,記憶體與運算卻整個翻上去。
   * 調大則蟲群變稀疏、效能變好,還原度也跟著下降。
   *
   * @default 1
   */
  particleGap?: number;
  /**
   * 粒子邊長相對取樣間距的倍率。
   *
   * 1 代表剛好貼齊取樣格。實際繪製時還會再多蓋一個裝置像素,
   * 因為邊長與格距相等時,行動裝置 GPU 會規律地漏掉整列而浮現橫縫,
   * 詳見 swarm-stage 的繪製邏輯。
   * 小於 1 會露出縫隙,大於 1 則讓相鄰粒子重疊更多,除非刻意想要顆粒感,
   * 否則不建議更動。
   *
   * @default 1
   */
  particleSize?: number;
  /**
   * 散開時的粒子邊長(px)。
   *
   * 貼齊取樣格的粒子只有一兩個裝置像素,飄出去後幾乎看不見,
   * 因此散開的粒子另外給尺寸,實際值不會小於靜止時的邊長。
   *
   * @default 2
   */
  scatterParticleSize?: number;
  /**
   * 粒子數量上限,超過時自動放大取樣間距(邊長會跟著等比放大)。
   *
   * 包裹的內容可大可小,沒有上限的話,滿版內容會一口氣生出上百萬顆粒子。
   *
   * 間距已改以 CSS 像素計算,粒子數只跟版面大小有關,不再隨螢幕像素比暴增,
   * 因此上限多半只在桌機的滿版內容才會碰到。粒子多寡直接反映在顯示卡記憶體上,
   * 100 萬顆約需 64MB,需要更省時再自行調低。
   *
   * @default 1000000
   */
  maxParticleCount?: number;
  /**
   * 粒子可飄出內容範圍的距離(px)。
   *
   * 畫布會依此值往四周各撐大一圈,太小的話散開的粒子會直接被裁掉,
   * 邊界處出現一條難看的直線。
   *
   * @default 150
   */
  scatterPadding?: number;
  /** 滑鼠擾動的影響半徑(px)。@default 60 */
  scatterRadius?: number;
  /** 擾動力道,越大散得越開。@default 40 */
  scatterForce?: number;
  /** 回歸速度,0~1 之間,越大聚回原位越快。@default 0.1 */
  returnSpeed?: number;
  /** 摩擦力,0~1 之間,越接近 1 慣性越強、飄得越久。@default 0.92 */
  friction?: number;
}

Emits

interface Emits {
  /** 蟲群完成初始化、開始模擬時觸發 */
  ready: [];
}

Methods

interface Expose {
  /** 讓蟲群立刻回到原位 */
  reset: () => void;
  /** 重新擷取內容圖片,內容或主題變更後呼叫 */
  refresh: () => Promise<void>;
}

Slots

interface Slots {
  /** 要化為蟲群的內容 */
  default?: () => unknown;
}

v0.86.3