每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
DOM 發射器
util可以發射任何網頁內容!੭ ˙∀˙ )੭
| 名稱 | 描述 |
|---|---|
| 物理模擬 | 模擬真實世界物理現象,如重力、碰撞、速度等物理效果 |
| Canvas 2D API | 基礎的 2D 繪圖 API,可以高效繪製比 DOM 更複雜的圖形 |
| Canvas Shader | 使用 GLSL 開發,直接在 GPU 上執行,比 Canvas 2D API 更快,但也更難 |
| Memoization | 藉由快取 function 計算結果,提高性能,避免重複計算 |
| DOM to Image | 將 DOM 元素轉換為圖片的技術,基於 SVG foreignObject 實現 |
| 粒子系統 | 產生大量小物件的系統,常用於模擬煙霧、火焰、雨雪等效果 |
放入 slot 的內容可以做為粒子發射,若 DOM 發生變化,粒子圖片也會自動更新。( •̀ ω •́ )✧
(點擊卡片可以切換樣式)
<template> <div class="w-full flex flex-col gap-4"> <util-dom-emitter ref="emitterRef" :slot-list="keys(slots)" class="grid justify-items-center gap-2 md:grid-cols-2" slot-wrapper-class=" w-2/3 md:w-full" canvas-z-index="9999" > <template v-for="(slot, name) in slots" #[name] :key="name" > <div class="grid grid-cols-3 grid-rows-2 h-full cursor-pointer gap-2 rounded p-2 text-sm" :class="slot.type" @click="nextType(slot)" > <div class="row-span-2 flex-center"> <img :src="slot.avatar" alt="" class="avatar row-span-2 aspect-square shrink-0 object-cover" @load="refreshDomImage()" > </div>
<span class="col-span-2 row-span-1 whitespace-pre-line" v-text="t(slot.valueKey)" />
<div class="col-span-2 row-span-1 flex"> <span v-for="n in slot.rate" :key="n" class="rate-star" />
<span class="flex-1 self-end text-right text-xs text-gray-300" v-text="slot.date" /> </div> </div> </template> </util-dom-emitter>
<div class="flex gap-2"> <base-input v-model="emitInterval" type="range" :label="`${t('發射週期')} : ${emitInterval}`" :min="1" :max="1000" class="example-ctrl flex-1" />
<base-checkbox v-model="enableEmit" :label="t('開始發射')" class="example-ctrl" /> </div> </div></template>
<script setup lang="ts">import { useIntervalFn } from '@vueuse/core'import { debounce } from 'lodash-es'import { nanoid } from 'nanoid'import { keys } from 'remeda'import { reactive, ref, useTemplateRef } from 'vue'import { useI18n } from 'vue-i18n'import BaseCheckbox from '../../base-checkbox.vue'import BaseInput from '../../base-input.vue'// import UtilDomEmitter from '../util-dom-emitter-2d.vue'import UtilDomEmitter from '../util-dom-emitter.vue'
const { t } = useI18n()
const typeList = ['t1', 't2', 't3', 't4'] as const
interface SlotDatum { avatar: string; rate: number; valueKey: string; type: typeof typeList[number]; date: string;}
const defaultSlotDatum: SlotDatum = { avatar: '/logo.webp', rate: 0, valueKey: '', type: 't1', date: '',}
const emitterRef = useTemplateRef('emitterRef')const slots = reactive({ [nanoid()]: { ...defaultSlotDatum, rate: 1, valueKey: 'msg1', date: '2023/10/01', }, [nanoid()]: { ...defaultSlotDatum, avatar: '/low/profile.webp', rate: 5, valueKey: 'msg2', date: '2022/12/06', }, [nanoid()]: { ...defaultSlotDatum, avatar: '/low//profile-2.webp', rate: 4, valueKey: 'msg3', date: '2024/04/03', }, [nanoid()]: { ...defaultSlotDatum, avatar: '/low//profile-3.webp', rate: 3, valueKey: 'msg4', date: '2025/07/11', },})
function nextType(data: SlotDatum) { const index = typeList.indexOf(data.type) + 1 data.type = typeList.at(index % typeList.length) ?? 't1'}
/** 偵測不到圖片載入變化,手動更新 DOM 圖片 */const refreshDomImage = debounce(() => { emitterRef.value?.refreshDomImage()}, 100)
const enableEmit = ref(false)const emitInterval = ref(500)useIntervalFn(() => { if (!enableEmit.value) { return }
emitterRef.value?.emit({ quantity: 4, x: [0, window.innerWidth], y: window.innerHeight + 80, vx: [-50, 50], vy: [-300, -800], a: 0, va: [-0.3, 0.3], })}, emitInterval)</script>
<style scoped lang="sass">.t1 color: #444 background: #FDFDFD border: 1px solid #DDD .avatar background: #c2eeff border-radius: 50% .rate-star &::before content: '★' color: #f59e0b.t2 border-radius: 0px background-color: #0d9488 border: 1px solid #0d9488 color: white .avatar background: #99f6e4 clip-path: polygon(30% 0%, 70% 0%, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0% 70%, 0% 30%) .rate-star &::before content: '🍕' color: #0d9488
.t3 border-radius: 1rem background-color: #fef2f2 border: 1px solid #fecaca color: #b91c1c .avatar border-radius: 1rem .rate-star &::before content: '❤️' color: #b91c1c.t4 border-radius: 999px padding-right: 2rem background-color: #fdf4ff border: 4px solid #f5d0fe color: #a855f7 .avatar border-radius: 999px background: #f5d0fe .rate-star &::before content: '🔥' color: #a855f7</style>取得 slot 中的 DOM 並轉換成圖片,接著將圖片作為粒子,模擬物理效果。
粒子資料與圖片都有了,最後就是在 Canvas 中繪製粒子即可。(・∀・)9
第一個使用 Shader 開發的元件登場,放個紀念煙火。✧⁑。٩(ˊᗜˋ*)و✧⁕。
一開始使用 Canvas 2D API 開發,但粒子數量一多,性能太過慘烈,所以決定改用 Shader 試試看。
說是這麼說,Shader 真的是有夠天書...(›´ω`‹ ),只好請來強大的 AI 幫我基於原本的 2D 版本,轉成 Shader 並詳細解釋。∠( ᐛ 」∠)_
所有的模型都試了一輪(Sonnet 4、GPT 4.1、GPT 4o、Gemini 2.5 Pro、GPT o3),任一個模型的程式碼都沒有辦法直接運行,都會有各種小錯誤,需要手動修正。
最後只有 GPT o3 的程式碼可以實際運行,其他模型的程式碼即使調整到沒有任何錯誤,也無法正常運行。( ˙꒳˙)
真心的覺得 AI 是個很好的老師,不然像以往那樣慢慢搜尋、看教學,半個毛線都沒生出來,熱情已經先消磨殆盡了。( ˘・з・)
現在可以更快速地驗證想法、實作有趣的點子,但也一定程度的降低了某些專業門檻。
只能說我們迎來了一個最好的時代,也是最壞的時代。(´・ω・`)
2D API vs Shader
Section titled “2D API vs Shader”我有保留 Canvas 2D API 版本,大家可以和目前 Shader 版本的程式碼比較看看。
不難發現 Shader 版本遠比 2D 版本複雜 N 倍以上,但是性能也提升了 N 倍以上,真的相當有趣。੭ ˙ᗜ˙ )੭
interface Props { slotList: SlotList; gravity?: number; slotWrapperClass?: string; canvasZIndex?: number | string;}Methods
Section titled “Methods”/** 粒子發射參數 * * [number, number] 表示此範圍隨機值 */interface ParticleParams { quantity: number; x: number | [number, number]; y: number | [number, number]; vx: number | [number, number]; vy: number | [number, number]; /** 角度 */ a: number | [number, number]; /** 角速度 */ va: number | [number, number];}
/** 發射特定 slot,或者所有 slot 平均 */type EmitParams = Record<keyof SlotList, ParticleParams> | ParticleParams
interface Expose { emit: (params: EmitParams) => void; /** 若粒子圖片與 DOM 有落差,可以手動更新 */ refreshDomImage: () => void;}type Slots = { [K in SlotList[number]]: () => unknown;}