蟲群包裝器 wrapper
包起來的內容全部化為蟲群,滑鼠一揮就散開,手離開後慢慢聚回原位。
鱈魚:「油女一族的秘傳忍術升級啦!ヽ(●´∀`●)ノ」
同事:「你的腦子終於裝滿 Bug 了?(´・ω・`)」
鱈魚:「說好的禮貌呢?ლ(╹ε╹ლ)」
技術關鍵字
| 名稱 | 描述 |
|---|---|
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| Canvas getImageData | 取得指定 Canvas 區域的像素資料 |
| Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
| Curl Noise | 基於 Noise 的無散度向量場,產生自然流動的粒子運動效果 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
| 物理模擬 | 模擬真實世界物理現象,如重力、碰撞、速度等物理效果 |
| 向量計算 | 處理方向、加速度、速度等等數學運算 |
| Pointer 事件 | 偵測滑鼠或觸控點移動、點擊、懸停等等事件,取得座標、目標等等資訊 |
使用範例
基本用法
滑鼠靠近時粒子散開,離開後慢慢聚回。

查看範例原始碼
<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>意願調查表
調查下午要不要喝飲料。♪( ◜ω◝و(و
同事:「這選項太過分了喔!ლ(´口`ლ)」
查看範例原始碼
<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>表單範例
沒填完沒得按。(╯•̀ὤ•́)╯
查看範例原始碼
<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 個裝置像素上。
兩個方框在畫面上一樣大,物理尺寸也一樣,差別只在切得多細。所以取樣間距選錯單位,成本會直接乘上倍率的平方。
同一塊 4 × 4 CSS 像素大的內容,在像素比 3 的手機上,兩種間距生出來的粒子數差了九倍。
粒子多了九倍,畫面沒有更好看,記憶體與運算卻整個翻上去。因此間距改以 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;
}