
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.8 | 0.01 |
| 霧氣起始 | 10 | 1 |
| 霧氣結束 | 100 | 30 |
光照幾乎關閉、霧氣逼近,整個場景瞬間變得昏暗朦朧。
水花延遲 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
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥