蟲群包裝器 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-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>意願調查表
調查下午要不要喝飲料。♪( ◜ω◝و(و
同事:「這選項太過分了喔!ლ(´口`ლ)」
查看範例原始碼
<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;
}