跳到內容

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

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 是個很好的老師,不然像以往那樣慢慢搜尋、看教學,半個毛線都沒生出來,熱情已經先消磨殆盡了。( ˘・з・)

現在可以更快速地驗證想法、實作有趣的點子,但也一定程度的降低了某些專業門檻。

只能說我們迎來了一個最好的時代,也是最壞的時代。(´・ω・`)

我有保留 Canvas 2D API 版本,大家可以和目前 Shader 版本的程式碼比較看看。

不難發現 Shader 版本遠比 2D 版本複雜 N 倍以上,但是性能也提升了 N 倍以上,真的相當有趣。੭ ˙ᗜ˙ )੭

interface Props {
slotList: SlotList;
gravity?: number;
slotWrapperClass?: string;
canvasZIndex?: number | string;
}
/** 粒子發射參數
*
* [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;
}