
HexaZen - EP01:前言
平常上班時耳機都常都是放白噪音、雨聲等等自然聲音。
同一個聲音聽太多次,連下一個雷聲甚麼時候出現我都快背起來了。(›´ω`‹ )
雖然有很多不錯的線上混音器網站,但是總感覺差了一點甚麼。
這次想嘗試用 Babylon.js 做一個 3D 白噪音混音器,聲音使用隨機播放的形式呈現。
說明
做一個有趣的白噪音網站,同時練習更複雜的 3D 應用
概念
每個聲音都是一個積木,積木可以任意組合,積木種類與規模會產生不同自然音效。
例如:
- 草:無
- 樹木:風吹過樹葉聲音
- 雲:下雨聲音
- 房子:咖啡廳聲音
- 河:流水聲
不同規模會產生生態系,例如樹木夠多會有蟲鳴、鳥叫等等。
地圖資訊會儲存在 URL 中,分享連結即可讓其他人聽到你的設計的自然音效。
素材

3D 積木則基於 kenney 大大設計的「Hexagon Kit」
感謝 kenney 大大提供如此優秀的素材!(*´∀`)~♥
開發
現在讓我們開始吧。੭ ˙ᗜ˙ )੭
第一步我打算先在此部落格進行原型測試。
歸功於 VitePress 強大的客製化能力,現在讓我們來建立一個完全客製化的頁面。
建立頁面
新增檔案。
content\aquarium\hexazen\index.md
---
title: HexaZen
description: 組合多種場景並產生不同的自然音效
image: https://codlin.me/hexazen-cover.webp
layout: false
---layout 設為 false 代表不使用預設的佈局,這樣就可以自由設計頁面了!♪( ◜ω◝و(و
TIP
詳細說明可見文件:VitePress:Layout
現在導航到 http://localhost:3000/aquarium/hexazen/ 應該會是一片空白。
接著在頁面中以 iframe 方式引入主元件,利用 whyframe 隔離環境。
content\aquarium\hexazen\index.md
---
title: HexaZen
description: 組合多種場景並產生不同的自然音效
image: https://codlin.me/hexazen-cover.webp
layout: false
---
<script setup>
import Hexazen from './hexazen.vue'
</script>
<iframe data-why class="fixed w-dvw h-dvh">
<hexazen />
</iframe>使用 iframe 隔離可以避免 VitePress 本身的樣式和狀態干擾,讓 HexaZen 擁有完全獨立的運行環境。
場景初始化
由於 Babylon.js 場景最基本構成是 engine、scene、camera、light。
所以我將相關邏輯直接獨立為 use-babylon-scene composable,簡化元件內容。
content\aquarium\hexazen\composables\use-babylon-scene.ts
export function useBabylonScene(param?: UseBabylonSceneParam) {
const canvasRef = ref<HTMLCanvasElement>()
const engine = shallowRef<BabylonEngine>()
const scene = shallowRef<Scene>()
const camera = shallowRef<ArcRotateCamera>()
onMounted(async () => {
if (!canvasRef.value)
return
engine.value = await createEngine({
canvas: canvasRef.value,
})
scene.value = createScene({
canvas: canvasRef.value,
engine: engine.value,
})
camera.value = createCamera({
canvas: canvasRef.value,
engine: engine.value,
scene: scene.value,
})
engine.value.runRenderLoop(() => {
scene.value?.render()
})
await init({ canvas, engine, scene, camera })
})
onBeforeUnmount(() => {
engine.value?.dispose()
scene.value?.dispose()
})
return { canvasRef, engine, scene, camera }
}支援 WebGPU 優先、WebGL 降級的引擎建立策略,並提供預設的場景、攝影機、光源設定,同時允許 init callback 自定義初始化邏輯。
現在在 3D 場景元件中引用。
content\aquarium\hexazen\main-scene.vue
<template>
<canvas v-once ref="canvasRef" class="w-full h-full outline-0" />
</template>
<script setup lang="ts">
import { useBabylonScene } from './composables/use-babylon-scene'
const { canvasRef, scene, camera } = useBabylonScene({
async init({ scene, engine, camera }) {
// 建立地板、陰影、粒子系統等
},
})
</script>接著產生地板。這裡使用 PBR 材質搭配 MirrorTexture 產生鏡面倒影效果。
function createGround({ scene }: { scene: Scene }) {
const ground = MeshBuilder.CreateGround(
'ground',
{ width: 1000, height: 1000 },
scene,
)
const pbr = new PBRMaterial('groundPBR', scene)
pbr.albedoColor = new Color3(0.9, 0.9, 0.9)
pbr.metallic = 0.2
pbr.roughness = 0.2
const mirrorTexture = new MirrorTexture('groundMirror', 512, scene, true)
mirrorTexture.mirrorPlane = new Plane(0, -1, 0, 0)
mirrorTexture.level = 0.5
mirrorTexture.renderList = []
pbr.reflectionTexture = mirrorTexture
ground.material = pbr
ground.receiveShadows = true
return ground
}現在場景中應該會有一個帶有鏡面反射的白色地板了。
資料驅動的積木定義
接著把模型放到靜態目錄中,下載模型並解壓縮。
content\public\assets\kenny-hexagon-pack
HexaZen 採用資料驅動的積木定義方式,所有積木的模型路徑、位置、旋轉、縮放等資訊都集中在 data.ts 中。
這裡的資料來自 CodStack 工具的輸出。
content\aquarium\hexazen\domains\block\builder\data.ts
interface BlockDefinition {
/** 一個 block 可能會有多個特性,例如港口同時有 water、building */
traitList: TraitTypeUnion[];
content: {
version: number;
rootFolderName: string;
partList: PartData[];
};
}
export const blockDefinitions = {
/** grass */
g1: {
traitList: ['grass'] as TraitTypeUnion[],
content: {
version: 1,
rootFolderName: 'assets',
partList: [
{
path: 'kenny-hexagon-pack/GLB format/grass.glb',
position: [0, 0, 0],
rotationQuaternion: [0, 1, 0, 0],
scaling: [1, 1, 1],
metadata: { name: '', mass: 0, restitution: 0.5, friction: 0 },
},
],
},
},
/** tree */
t1: {
traitList: ['tree'] as TraitTypeUnion[],
content: {
version: 1,
rootFolderName: 'assets',
partList: [
{
path: 'kenny-hexagon-pack/GLB format/grass-forest.glb',
position: [0, 0, 0],
rotationQuaternion: [0, 1, 0, 0],
scaling: [1, 1, 1],
metadata: { name: '', mass: 0, restitution: 0.5, friction: 0 },
},
],
},
},
// ... 共 30+ 種積木
} satisfies Record<string, BlockDefinition>每個積木定義了 traitList(特性清單),這是聲景系統的判斷依據。例如港口(b6)同時擁有 building 和 water 兩個特性。
建立積木
統一的 createBlock function 負責載入模型、設定材質、掛載到場景上。
content\aquarium\hexazen\domains\block\builder\index.ts
export async function createBlock({
type,
scene,
shadowGenerator,
hex,
hexLayout,
weather,
}: CreateBlockParams): Promise<Block> {
const blockDefinition = blockDefinitions[type]
const resultList = await Promise.all(
blockDefinition.content.partList.map(
async ({ path, position, rotationQuaternion, scaling, metadata }) => {
const fullPath = `${blockDefinition.content.rootFolderName}/${path}`
const model = await ImportMeshAsync(fullPath, scene)
// 強制三線性過濾,避免旋轉時紋理閃爍
model.meshes.forEach((mesh) => {
if (mesh.material instanceof PBRMaterial) {
mesh.material.metallic = 0
mesh.material.roughness = 0.4
}
})
return { model, position, rotationQuaternion, scaling }
},
),
)
const rootNode = new TransformNode('block-root', scene)
// 組裝所有部件到 rootNode
resultList.forEach(({ model, position, rotationQuaternion, scaling }) => {
const [rootMesh] = model.meshes
if (!rootMesh)
return
rootMesh.receiveShadows = true
rootMesh.position = new Vector3(...position)
rootMesh.rotationQuaternion = new Quaternion(...rotationQuaternion)
rootMesh.scaling = new Vector3(...scaling)
rootMesh.parent = rootNode
})
// 放置到六角格世界座標
rootNode.position.copyFrom(hexLayout.hexToWorld(hex))
// 進入動畫:從下方彈出
rootNode.position.y -= 1
await animate(rootNode.position, {
y: 0,
duration: 1000,
ease: 'outElastic(1,0.52)',
}).then()
return { type, rootNode, hex, dispose }
}專案結構
目前的檔案結構如下:
hexazen/
├── hexazen.vue # 應用根元件
├── main-scene.vue # 3D 場景
├── index.md # VitePress 入口
├── components/ # UI 元件
├── composables/ # 共用邏輯
│ ├── use-babylon-scene.ts # 引擎初始化
│ ├── use-simple-i18n.ts # 輕量 i18n
│ ├── use-thumbnail-generator.ts
│ └── use-font-loader.ts
├── constants/
│ └── index.ts # 版本號
├── types/
│ └── index.ts # Trait、Weather 型別
└── domains/
├── block/ # 積木系統
│ ├── type.ts
│ ├── block-picker.vue
│ ├── builder/ # 建立積木
│ └── trait-region/ # BFS 連通區域
├── hex-grid/ # 六角格數學
├── soundscape/ # 聲景系統
│ ├── type.ts
│ ├── soundscape-mixer.vue
│ ├── player/ # 播放引擎
│ └── resolver/ # 規則解析
└── share/codec/ # URL 編碼總結 🐟
以上程式碼可以在此取得
- 建立 whyframe 隔離的 VitePress 客製化頁面
- 建立 WebGPU/WebGL 雙策略的 Babylon.js 場景
- PBR 材質地板搭配鏡面反射
- 資料驅動的積木定義系統
- 統一的積木建立函式與彈性動畫
下一章讓我們來看六角格座標系統與聲景規則的設計吧。♪( ◜ω◝و(و
TIP
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥