iT邦幫忙

2024 iThome 鐵人賽

DAY 14
2

所以我們要怎麼實現彩帶的效果呢?用大量的 div 嗎?

其實還真的可行,只是畫面可能會卡到爆炸。(›´ω`‹ )

為了效果與性能兼具,這裡使用 canvas 繪製彩帶。

讓我們透過 babylon.js 實現效果吧。( ´ ▽ ` )ノ

先來安裝 babylon。

npm i -D @babylonjs/core

產生 3D 場景

babylon.js 產生一個場景最低限度至少需要以下物件:

  • canvas:繪製畫面
  • engine:負責執行計算
  • scene:定義場景
  • camera:看到場景內容

讓我們建立第一個 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 個參數。

  • alpha:縱向旋轉,以弧度為單位
  • beta:緯度旋轉,以弧度為單位
  • radius:半徑,與目標的距離

具體概念如下圖。

image.png

其他細節可以參考官方文件:Camera Introduction

會看到畫面跑出一片黑黑的東東。

image.png

鱈魚:「恭喜我們產生了第一個 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>

產生一個方塊並旋轉一下。

image.png

鱈魚:「鏘將!◝( •ω• )◟」

路人:「那個黑嚕嚕的東西是立方體?╭(°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

立方體出現了!(´,,•ω•,,)

image.png

現在我們確認場景可以正常運作了,來建立彩帶效果需要的粒子系統吧。( ´ ▽ ` )ノ

建立粒子系統

這裡使用 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 提供的隨機函數,可以簡單取出指定範圍的隨機值。

可以看到畫面跑出了一推紙片。

image.png

成功產生粒子了,現在讓粒子動起來吧!♪( ◜ω◝و(و

定義粒子更新邏輯。

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>

動畫.gif

現在讓我們把背景改為透明,不要一片黑。

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>

現在看起來有點樣子了。( ´ ▽ ` )ノ

動畫.gif

讓粒子的運動效果複雜一點,加上旋轉、空氣阻力與擾動並微調參數。

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>

現在看起來非常有模有樣了!◝(≧∀≦)◟

動畫.gif

現在讓我們開始定義元件參數,順便上個顏色吧。ლ(´∀`ლ)

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>

終於像彩帶了!ヾ(◍'౪`◍)ノ゙

動畫.gif

不過看起來有點黑嚕嚕的,看起來很不派對,這是因為陰影的關係,讓我們調整燈光的陰影顏色。

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>

現在看起來好多了。◝( •ω• )◟

image.png

彩帶已就位,最後來實作發射彩帶的邏輯。( ´ ▽ ` )ノ🎉

發射彩帶

我們先在元件上新增參數,用來控制連續發射數與每次發射的彩帶數量,同時調整初始化邏輯,一開始隱藏所有粒子並刪除原本的隨機位置邏輯

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>

效果看起來超棒!✧⁑。٩(ˊᗜˋ*)و✧⁕。

動畫.gif

有興趣的話也可以來這裡實際玩玩看喔!੭ ˙ᗜ˙ )੭

總結

  • 建立 3D 場景並建立粒子系統
  • 完成「拉炮」元件
  • 完成「拉炮」的 basic-usage 範例

以上程式碼已同步至 GitLab,大家可以前往下載:

GitLab - D14


上一篇
D13 - 拉炮:分析需求
下一篇
D15 - 拉炮:單元測試
系列文
要不要 Vue 點酷酷的元件?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
Sunny.Cat
iT邦新手 3 級 ‧ 2024-09-28 10:52:19

鱈大大這篇很讚!!!

鱈魚 iT邦研究生 5 級 ‧ 2024-09-28 11:22:13 檢舉

灑花!✧⁑。٩(ˊᗜˋ*)و✧⁕。

我要留言

立即登入留言