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-20 py-10">
    <wrapper-swarm :ref="swarmRefList.set">
      <div class="text-4xl font-bold">
        {{ t('title') }}
      </div>
    </wrapper-swarm>

    <wrapper-swarm :ref="swarmRefList.set">
      <img
        src="/low/profile.webp"
        alt=""
        class="w-40 border rounded-full object-cover"
      >
    </wrapper-swarm>

    <wrapper-swarm :ref="swarmRefList.set">
      <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>
    </wrapper-swarm>
  </div>
</template>

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

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

const swarmRefList = useTemplateRefsList<
  InstanceType<typeof WrapperSwarm>
>()

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

原理

先把 DOM 拍成照片

snapdom 把插槽內容拍成畫布,getImageData 逐點取樣,不透明的像素才生成粒子,原始 DOM 轉為透明退居幕後。跟蟲群文字最大的差別在顏色,文字只有單一色彩,包裝器吃進來的卻可能是彩色卡片、照片、漸層背景,所以每顆粒子都要多記一張紋理,存下來源像素的 RGB。

取樣間距用裝置像素計算,預設一顆粒子對應一個實體像素,邏輯像素在 Retina 螢幕會白白丟掉一半細節。

內容有大有小,滿版內容照預設間距取樣會生出上百萬顆粒子,因此加了 maxParticleCount 當煞車,超標就放大間距重新取樣。判斷用實際粒子數,不用寬高估算,不然只有筆畫會生粒子的文字,得為整塊矩形買單,白白拉低密度。

物理模擬全部丟給 GPU

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

擾動來自 curl noise,每幀重算 FBM 太浪費,改成開場先在 GPU 算好一張 256×256 查表紋理,之後全部改用取樣。

讓蟲群像活的

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

回歸時如果粒子都乖乖走直線,看起來只會是整齊方陣,一點都不像蟲,所以離原位越遠的粒子額外吃一份低頻噪聲,把鄰近的粒子往同個方向推,形成一小撮一小撮的群聚感。每顆粒子還有專屬的隨機因子,摩擦力、力道、回歸速率各自略有差異,整群才不會同進同出。

原始碼

API

Props

interface Props {
  /** 關閉後回到原本 DOM,不生成任何粒子。@default true */
  enabled?: boolean;
  /**
   * 粒子取樣間距,單位為裝置像素。
   *
   * 1 代表一顆粒子對應螢幕上一個實體像素,靜止時與原內容幾乎無異。
   * 調大則蟲群變稀疏、效能變好,還原度也跟著下降。
   *
   * @default 1
   */
  particleGap?: number;
  /**
   * 粒子邊長相對取樣間距的倍率。
   *
   * 1 代表剛好貼齊取樣格,小於 1 會露出縫隙,
   * 除非刻意想要顆粒感,否則不建議調小。
   *
   * @default 1.2
   */
  particleSize?: number;
  /**
   * 散開時的粒子邊長(px)。
   *
   * 貼齊取樣格的粒子只有一兩個裝置像素,飄出去後幾乎看不見,
   * 因此散開的粒子另外給尺寸,實際值不會小於靜止時的邊長。
   *
   * @default 2.5
   */
  scatterParticleSize?: number;
  /**
   * 粒子數量上限,超過時自動放大取樣間距(邊長會跟著等比放大)。
   *
   * 包裹的內容可大可小,沒有上限的話,滿版內容會一口氣生出上百萬顆粒子。
   *
   * @default 250000
   */
  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.72.0