Skip to content

hexazen-cover

HexaZen - EP05:天氣與粒子系統

一片晴天固然美好,但如果能下場雨就更有感覺了。ˋ( ° ▽、° )

這章來實作天氣系統,GPU 粒子雨滴、射線投射水花、以及動態的光照與霧氣變化。੭ ˙ᗜ˙ )੭

天氣狀態機

天氣有三種模式,透過 useCycleList 循環切換:

typescript
const { state: weatherMode, next: nextWeatherMode } = useCycleList<
  Weather | undefined | 'random-rain'
>([
  undefined, // 晴天
  'rain', // 雨天
  'random-rain', // 隨機(每 5 分鐘擲骰子)
])

useIntervalFn(
  () => {
    if (weatherMode.value === 'random-rain') {
      currentWeather.value = Math.random() < 0.5 ? 'rain' : undefined
    }
  },
  1000 * 60 * 5,
)

隨機降雨模式每 5 分鐘擲一次骰子,50% 機率下雨。

GPU 粒子系統:雨滴

雨滴使用 GPUParticleSystem,容量 50,000 顆粒子,比 CPU 版本高效得多。

typescript
function createRainSystem(scene: Scene) {
  if (!GPUParticleSystem.IsSupported) {
    console.warn('此裝置不支援 GPU 粒子系統')
    return
  }

  const particleSystem = new GPUParticleSystem(
    'rain_system', { capacity: 50000 }, scene,
  )

  // 動態繪製雨滴紋理:一條半透明的漸層直線
  const dropTexture = new DynamicTexture(
    'drop_tex', { width: 1, height: 20 }, scene, false,
  )
  const ctx = dropTexture.getContext()
  const gradient = ctx.createLinearGradient(0, 0, 0, 32)
  gradient.addColorStop(0, 'rgba(255, 255, 255, 0)')
  gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.6)')
  gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, 4, 32)
  dropTexture.update()

  particleSystem.particleTexture = dropTexture

紋理不是用圖片檔,而是用 DynamicTexture 動態繪製一條上下漸隱的白線,看起來就像細細的雨絲。

發射器設定

typescript
const emitter = new BoxParticleEmitter()
emitter.direction1 = new Vector3(0, -1, 0)
emitter.direction2 = new Vector3(0, -1, 0)
emitter.minEmitBox = new Vector3(-5, 4, -5)
emitter.maxEmitBox = new Vector3(5, 4, 5)

particleSystem.particleEmitterType = emitter
particleSystem.emitter = Vector3.Zero()

particleSystem.billboardMode = ParticleSystem.BILLBOARDMODE_STRETCHED
particleSystem.color1 = new Color4(0.8, 0.8, 0.9, 0.2)
particleSystem.color2 = new Color4(0.6, 0.7, 0.8, 0.3)

particleSystem.minSize = 0.01
particleSystem.maxSize = 0.01
particleSystem.minScaleY = 5.0
particleSystem.maxScaleY = 5.0

particleSystem.emitRate = 2000
particleSystem.gravity = new Vector3(0, -1, 0)

重點:

  • BILLBOARDMODE_STRETCHED 讓粒子沿移動方向拉伸,呈現雨絲效果
  • minScaleY = 5.0 把每顆粒子拉成細長的雨滴形狀
  • 從 10×10 的方形區域往下垂直降落

CPU 粒子系統:水花

雨滴打到地面或積木上會濺起水花。這裡用 CPU 粒子系統,因為需要 ray-cast 來決定水花出生位置。

typescript
function createSplashSystem(scene: Scene) {
  const splashSystem = new ParticleSystem(
    'splash_system', 10000, scene,
  )

  // 圓形水花紋理
  const splashTexture = new DynamicTexture(
    'splash_tex', { width: 32, height: 32 }, scene, false,
  )
  const ctx = splashTexture.getContext()
  const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16)
  gradient.addColorStop(0, 'rgba(200, 220, 240, 0.8)')
  gradient.addColorStop(1, 'rgba(200, 220, 240, 0)')
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, 32, 32)
  splashTexture.update()

  splashSystem.particleTexture = splashTexture

射線投射定位

這是水花系統最精巧的部分,用射線投射來找到地面或積木的表面位置。

typescript
  splashSystem.startPositionFunction = (
    _worldMatrix, positionToUpdate,
  ) => {
    const randomX = Math.random() * 10 - 5
    const randomZ = Math.random() * 10 - 5

    // 從高空垂直向下發射射線
    const rayStart = new Vector3(randomX, 5, randomZ)
    const rayDir = new Vector3(0, -1, 0)
    const ray = new Ray(rayStart, rayDir, 30)

    // 偵測射線打到了場景中的哪個表面
    const hit = scene.pickWithRay(ray, (mesh) => {
      return mesh.isVisible && mesh.isPickable
    })

    if (hit && hit.hit && hit.pickedPoint) {
      positionToUpdate.copyFrom(hit.pickedPoint)
      positionToUpdate.y += 0.01  // 稍微抬高避免被模型吃掉
    } else {
      // 沒打到東西就藏到畫面外
      positionToUpdate.copyFromFloats(randomX, -100, randomZ)
    }
  }

  return splashSystem
}

startPositionFunction 覆寫了粒子的出生位置。每顆水花粒子出生前,先從空中往下射一條光線,打到什麼表面就在那裡濺起水花。

這樣水花就會精確地出現在積木頂部、地面上,而不會浮在空中。ヽ(●`∀´●)ノ

天氣轉場

切換天氣時,光照和霧氣也要配合變化,使用 3 秒動畫做平滑過渡。

typescript
watch(
  () => ({
    isRain: props.weather === 'rain',
    scene: scene.value,
  }),
  ({ isRain, scene: sceneValue }) => {
    if (!sceneValue)
      return

    // 粒子系統啟停(水花延遲 3 秒)
    if (isRain) {
      rainParticleSystem.value?.start()
      promiseTimeout(3000).then(() => {
        splashParticleSystem.value?.start()
      })
    }
    else {
      rainParticleSystem.value?.stop()
      promiseTimeout(3000).then(() => {
        splashParticleSystem.value?.stop()
      })
    }

    // 光照漸變
    const shadowLight = shadowGenerator.value?.getLight()
    if (shadowLight) {
      animate(shadowLight, {
        intensity: isRain ? 0.01 : 0.8,
        duration: 3000,
      })
    }

    // 霧氣漸變
    animate(sceneValue, {
      fogStart: isRain ? 1 : 10,
      fogEnd: isRain ? 30 : 100,
      duration: 3000,
    })
  },
)

下雨時的變化:

屬性晴天雨天
光照強度0.80.01
霧氣起始101
霧氣結束10030

光照幾乎關閉、霧氣逼近,整個場景瞬間變得昏暗朦朧。

水花延遲 3 秒才開始,因為雨滴需要時間「落下來」。( •̀ ω •́ )✧

營火煙霧:天氣連動

營火積木有煙霧粒子效果,但下雨時火被澆熄了,煙霧也要停止:

typescript
const scope = effectScope()
scope.run(() => {
  watch(weather, (value) => {
    if (value === 'rain') {
      smoothParticleSystem?.stop()
    }
    else {
      smoothParticleSystem?.start()
    }
  })
})

使用 Vue 的 effectScope 確保清理時不會留下殘餘的 watcher,此概念源自此文章

總結 🐟

以上程式碼可以在此取得

  • GPU 粒子系統高效渲染 5 萬顆雨滴
  • DynamicTexture 動態繪製紋理,不需額外圖片
  • 射線投射讓水花精確定位在表面上
  • 3 秒轉場動畫確保天氣切換流暢自然
  • 營火煙霧與天氣連動,注重細節

下一章讓我們來看積木選擇器與縮圖生成機制吧。♪( ◜ω◝و(و

TIP

因為篇幅問題,某些部分簡單帶過。

若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥