所以我們要怎麼實現彩帶的效果呢?用大量的 div 嗎?
其實還真的可行,只是畫面可能會卡到爆炸。(›´ω`‹ )
為了效果與性能兼具,這裡使用 canvas 繪製彩帶。
讓我們透過 babylon.js 實現效果吧。( ´ ▽ ` )ノ
先來安裝 babylon。
npm i -D @babylonjs/core
babylon.js 產生一個場景最低限度至少需要以下物件:
讓我們建立第一個 3D 場景。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
import { onMounted, ref, shallowRef } from 'vue';
import {
Camera,
Scene,
Engine,
Vector3,
ArcRotateCamera,
} from '@babylonjs/core';
...
const canvasRef = ref<HTMLCanvasElement>();
const engine = shallowRef<Engine>();
const scene = shallowRef<Scene>();
const camera = shallowRef<Camera>();
function createScene(engine: Engine) {
const scene = new Scene(engine);
return scene;
}
function createCamera(scene: Scene) {
const camera = new ArcRotateCamera(
'camera',
-Math.PI / 2,
Math.PI / 2,
10,
new Vector3(0, 0, 0),
scene,
);
return camera;
}
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
engine.value = new Engine(canvas, true, {
alpha: true,
});
scene.value = createScene(engine.value);
camera.value = createCamera(scene.value);
/** 視窗尺寸變更時,呼叫 engine resize */
window.addEventListener('resize', handleResize);
/** 反覆渲染場景,這樣畫面才會持續變化 */
engine.value.runRenderLoop(() => {
scene.value?.render();
});
});
/** 元件解除時,釋放資源 */
onBeforeUnmount(() => {
engine.value?.dispose();
scene.value?.dispose();
window.removeEventListener('resize', handleResize);
});
function handleResize() {
engine.value?.resize();
}
...
</script>
Engine 和 Scene 很單純,讓我們來說說 Camera。◝( •ω• )◟
這裡使用 ArcRotateCamera,他是一種可以圍繞著目標旋轉的相機,就像月球繞著地球跑那樣,主要有 3 個參數。
具體概念如下圖。
其他細節可以參考官方文件:Camera Introduction
會看到畫面跑出一片黑黑的東東。
鱈魚:「恭喜我們產生了第一個 3D 場景!◝( •ω• )◟」
路人:「最好是有 3D 啦!Σ(ˊДˋ;)」
鱈魚:「那就來一個立方體證明一下。(´,,•ω•,,)」
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
onMounted(() => {
...
camera.value = new ArcRotateCamera(...);
const box = MeshBuilder.CreateBox('box', {}, scene.value);
box.rotation.x = Math.PI / 4;
box.rotation.z = Math.PI / 4;
...
});
...
</script>
產生一個方塊並旋轉一下。
鱈魚:「鏘將!◝( •ω• )◟」
路人:「那個黑嚕嚕的東西是立方體?╭(°A ,°`)╮」
鱈魚:「哎呀,少了燈光。(´,,•ω•,,)」
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
onMounted(() => {
...
const light = new HemisphericLight('light', new Vector3(-0.5, 1, -0.5), scene.value);
const box = MeshBuilder.CreateBox('box', {}, scene.value);
...
});
...
</script>
Hemispheric Light 是一種半球光,想像光的分布像天頂一樣的半球在環境中散射與漫射,有更自然、柔和的照明效果,是定義環境光最簡單的方式。
其他細節可以參考官方文件:Introduction To Lights
立方體出現了!(´,,•ω•,,)
現在我們確認場景可以正常運作了,來建立彩帶效果需要的粒子系統吧。( ´ ▽ ` )ノ
這裡使用 SolidParticleSystem,顧名思義就是一個基於 mesh 的粒子系統。
比起自行新增多個 mesh,使用 SolidParticleSystem 可以更有效率的繪製多個粒子,且方便統一管理。
讓我們新增 createParticleSystem。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
const camera = shallowRef<Camera>();
const particleSystem = shallowRef<SolidParticleSystem>();
interface CreateParticlesParam {
scene: Scene;
}
function createParticleSystem({ scene }: CreateParticlesParam) {
const particleSystem = new SolidParticleSystem('particleSystem', scene);
// 建立粒子 mesh
const mesh = MeshBuilder.CreateBox('mesh', {
width: 1, height: 1, depth: 0.02,
})
// 產生 100 個粒子
particleSystem.addShape(mesh, 100);
// 將 mesh 加入粒子系統後,就不需要原本的 mesh 了,所以釋放 mesh
mesh.dispose();
// 建構粒子系統
particleSystem.buildMesh();
// 定義初始化每個粒子用的 function
particleSystem.initParticles = () => {
particleSystem.particles.forEach((particle) => {
// 隨機位置
particle.position.x = Scalar.RandomRange(-5, 5)
particle.position.y = Scalar.RandomRange(-5, 5)
// 隨機旋轉
particle.rotation.x = Scalar.RandomRange(0, Math.PI * 2);
particle.rotation.y = Scalar.RandomRange(0, Math.PI * 2);
});
};
// 執行初始化
particleSystem.initParticles();
// 設定所有粒子並更新網格
particleSystem.setParticles();
return particleSystem;
}
onMounted(() => {
...
const light = new HemisphericLight('light', new Vector3(-0.5, 1, -0.5), scene.value);
particleSystem.value = createParticleSystem({
scene: scene.value
});
/** 視窗尺寸變更時,呼叫 engine resize */
...
});
/** 元件解除時,釋放資源 */
onBeforeUnmount(() => {
engine.value?.dispose();
scene.value?.dispose();
particleSystem.value?.dispose();
...
});
...
</script>
Scalar.RandomRange 是 babylon 提供的隨機函數,可以簡單取出指定範圍的隨機值。
可以看到畫面跑出了一推紙片。
成功產生粒子了,現在讓粒子動起來吧!♪( ◜ω◝و(و
定義粒子更新邏輯。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
// 設定所有粒子並更新網格
particleSystem.setParticles();
particleSystem.updateParticle = (particle) => {
// 重力對速度的影響
particle.velocity.y += -0.01;
// 速度對位置的影響
particle.position.addInPlace(particle.velocity);
return particle;
}
/** 播放動畫 */
scene.onAfterRenderObservable.add(() => {
particleSystem.setParticles();
})
return particleSystem;
}
...
</script>
現在讓我們把背景改為透明,不要一片黑。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createScene(engine: Engine) {
const scene = new Scene(engine);
// 背景透明
scene.clearColor = new Color4(0, 0, 0, 0);
return scene;
}
...
</script>
取得 canvas 尺寸並計算場景邊界。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
const canvasRef = ref<HTMLCanvasElement>();
const canvasBounding = reactive(
useElementBounding(canvasRef),
);
/** 畫布邊界 */
const canvasBoundary = computed(() => {
// 抓大一點的範圍,讓粒子可以飄到畫布外
const x = canvasBounding.width / 3 * 2;
const y = canvasBounding.height / 3 * 2;
return {
left: -x,
right: x,
top: y,
bottom: -y,
}
});
...
</script>
接著調整鏡頭距離與投影方式,因為這裡不需要透視變形效果,所以採用正射投影,以免彩帶有在邊界產生明顯的透視變形。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createCamera(scene: Scene) {
const camera = new ArcRotateCamera(
'camera',
-Math.PI / 2,
Math.PI / 2,
Math.max(canvasBounding.width, canvasBounding.height),
new Vector3(0, 0, 0),
scene,
);
camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
return camera;
}
...
</script>
最後調整一下彩帶尺寸與隨機範圍,不然看不到。XD
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
// 建立粒子 mesh
const mesh = MeshBuilder.CreateBox('mesh', {
width: 10, height: 10, depth: 0.2,
})
...
particleSystem.initParticles = () => {
particleSystem.particles.forEach((particle) => {
// 隨機位置
particle.position.x = Math.random() * canvasBounding.width - canvasBounding.width / 2;
particle.position.y = Math.random() * canvasBounding.height - canvasBounding.height / 2;
...
});
};
...
}
...
</script>
現在看起來有點樣子了。( ´ ▽ ` )ノ
讓粒子的運動效果複雜一點,加上旋轉、空氣阻力與擾動並微調參數。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
// 定義初始化每個粒子用的 function
particleSystem.initParticles = () => {
particleSystem.particles.forEach((particle) => {
...
// 隨機角速度。將角速度存在 particle 的 props 中
particle.props = {
rotationVelocity: new Vector3(
Scalar.RandomRange(-0.1, 0.1),
Scalar.RandomRange(-0.1, 0.1),
Scalar.RandomRange(-0.1, 0.1),
)
};
});
};
...
particleSystem.updateParticle = (particle) => {
// 模擬空氣擾動
particle.velocity.addInPlaceFromFloats(
Scalar.RandomRange(-0.2, 0.2),
Scalar.RandomRange(-0.2, 0.2),
0
);
// 重力對速度的影響
particle.velocity.y += -0.03;
// 空氣阻力,讓粒子逐漸減速
const airResistance = 0.985;
particle.velocity.x *= airResistance;
particle.velocity.y *= airResistance;
// 速度對位置的影響
particle.position.addInPlace(particle.velocity);
// 角速度對旋轉的影響
if (particle?.props?.rotationVelocity) {
particle.rotation.addInPlace(particle.props.rotationVelocity);
}
return particle;
}
...
}
...
</script>
現在看起來非常有模有樣了!◝(≧∀≦)◟
現在讓我們開始定義元件參數,順便上個顏色吧。ლ(´∀`ლ)
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
interface Color {
/** 紅。0 ~ 1 */
r: number;
/** 綠。0 ~ 1 */
g: number;
/** 藍。0 ~ 1 */
b: number;
}
// #region Props
interface Props {
/** 粒子顏色,給 function 則可自訂顏色選擇邏輯 */
color?: Color | ((index: number) => Color);
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
color: () => () => {
const colors = [
{ r: 1, g: 0.4, b: 0, },
{ r: 1, g: 0.9, b: 0, },
{ r: 0.5, g: 1, b: 0, },
{ r: 0, g: 0.9, b: 1, },
] as const;
/** 隨機取一個 */
const [
target = colors[0],
] = sample(colors, 1);
return target;
},
});
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
// 定義初始化每個粒子用的 function
particleSystem.initParticles = () => {
particleSystem.particles.forEach((particle) => {
// 設定粒子顏色
const color = props.color instanceof Function
? props.color(particle.idx)
: props.color;
particle.color = new Color4(
color.r, color.g, color.b, 1
);
...
});
};
...
}
...
</script>
終於像彩帶了!ヾ(◍'౪`◍)ノ゙
不過看起來有點黑嚕嚕的,看起來很不派對,這是因為陰影的關係,讓我們調整燈光的陰影顏色。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
onMounted(() => {
...
const light = new HemisphericLight('light', new Vector3(-0.5, 1, -0.5), scene.value);
light.groundColor = new Color3(0.9, 0.9, 0.9);
...
});
...
</script>
現在看起來好多了。◝( •ω• )◟
彩帶已就位,最後來實作發射彩帶的邏輯。( ´ ▽ ` )ノ🎉
我們先在元件上新增參數,用來控制連續發射數與每次發射的彩帶數量,同時調整初始化邏輯,一開始隱藏所有粒子並刪除原本的隨機位置邏輯
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
// #region Props
interface Props {
/** 每次發射數量
*
* @default 20
*/
quantityOfPerEmit?: number;
/** 最大同時觸發次數。
*
* @default 10
*/
maxConcurrency?: number;
...
}
// #endregion Props
const props = withDefaults(defineProps<Props>(), {
quantityOfPerEmit: 20,
maxConcurrency: 10,
...
});
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
// 產生足夠數量的粒子
particleSystem.addShape(mesh, props.maxConcurrency * props.quantityOfPerEmit);
...
// 定義初始化每個粒子用的 function
particleSystem.initParticles = () => {
particleSystem.particles.forEach((particle) => {
// 隱藏粒子
particle.isVisible = false;
particle.alive = false;
...
});
};
...
}
...
</script>
加上邊界邏輯,將超出邊界的粒子隱藏並略過更新,減少不必要的計算。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
function createParticleSystem({ scene }: CreateParticlesParam) {
...
particleSystem.updateParticle = (particle) => {
if (!particle.isVisible) return particle;
if (particle.position.y > canvasBoundary.value.top
|| particle.position.y < canvasBoundary.value.bottom
|| particle.position.x < canvasBoundary.value.left
|| particle.position.x > canvasBoundary.value.right
) {
particle.isVisible = false;
particle.alive = false;
return particle;
}
// 模擬空氣擾動
...
}
...
}
...
</script>
追加一下元件的事件定義。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
// #region Emits
const emits = defineEmits<{
'emit': [groupIndex: number];
}>();
// #endregion Emits
...
</script>
最後讓我們新增 emit,用來發射粒子。
src\components\util-party-popper\util-party-popper.vue
...
<script setup lang="ts">
...
/** 用來計算發射到第幾組,讓粒子輪流發射 */
let groupIndex = 0;
/** 發射位置與速度向量 */
interface EmitParam {
x: number;
y: number;
velocity?: Vector
}
/** 發射彩帶,如果提供 function,可以針對每個粒子調整 */
function emit(param: EmitParam | ((index: number) => EmitParam)) {
if (!particleSystem.value) return;
for (let i = 0; i < props.quantityOfPerEmit; i++) {
const { x, y, velocity } = pipe(param,
(data) => {
if (data instanceof Function) {
return data(i);
}
return data;
},
(data) => ({
/** babylon 的 0 點是畫面中心,網頁 0 點是左上角,
* 而且 y 軸方向相反,讓我們轉換一下座標系統
*/
x: data.x - canvasBounding.width / 2,
y: -(data.y - canvasBounding.height / 2),
velocity: data.velocity,
}),
);
/** 根據 groupIndex 取得正確 index */
const index = i + groupIndex * props.quantityOfPerEmit;
if (index === undefined) continue;
const particle = particleSystem.value.particles[index];
if (!particle) continue;
/** 顯示粒子 */
particle.isVisible = true;
particle.alive = true;
/** 設定位置與速度 */
particle.position = new Vector3(x, y, 0);
if (velocity) {
particle.velocity = new Vector3(
velocity.x, velocity.y, 0
);
}
}
emits('emit', groupIndex);
groupIndex++;
groupIndex %= props.maxConcurrency; 'emit': [groupIndex: number];
}
// #region Methods
defineExpose({
emit,
});
// #endregion Methods
</script>
現在讓我們調整一下 basic-usage,讓滑鼠點擊位置發射粒子。
src\components\util-party-popper\examples\basic-usage.vue
<template>
<div
ref="containerRef"
class="flex flex-col gap-4 w-full border border-gray-300"
@click="emit()"
>
<util-party-popper ref="popperRef" />
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import UtilPartyPopper from '../util-party-popper.vue';
import { useMouseInElement } from '@vueuse/core';
const popperRef = ref<InstanceType<typeof UtilPartyPopper>>();
const containerRef = ref<HTMLDivElement>();
/** 取得滑鼠在容器的位置 */
const mouse = reactive(useMouseInElement(containerRef))
function emit() {
// 彩帶在滑鼠點擊處發射,並隨機給予 -5~5 之間的速度
popperRef.value?.emit(() => ({
x: mouse.elementX,
y: mouse.elementY,
velocity: {
x: Math.random() * 10 - 5,
y: Math.random() * 10 - 5,
},
}));
}
</script>
效果看起來超棒!✧⁑。٩(ˊᗜˋ*)و✧⁕。
有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭
以上程式碼已同步至 GitLab,大家可以前往下載: