
HexaZen - EP04:3D 場景互動
有了六角格和音效系統,現在要讓使用者能在 3D 場景中實際操作積木。
點擊空格放置、點擊積木旋轉、切換鏟子模式移除。੭ ˙ᗜ˙ )੭
地板:PBR 材質與鏡面反射
首先讓地板看起來更有質感。使用 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
// 自動將新增的 mesh 加入倒影渲染清單
scene.onNewMeshAddedObservable.add((mesh) => {
if (mesh !== ground) {
mirrorTexture.renderList?.push(mesh)
}
})
return ground
}mirrorTexture.level = 0.5 讓倒影若隱若現,不會太搶眼。
搭配 onNewMeshAddedObservable,後續新增的積木也會自動出現在倒影中。
候選格、選中格、Hover 狀態
六角格有三種視覺狀態:
| 狀態 | 透明度 | 顏色 | 可點擊 |
|---|---|---|---|
| 候選格 (Candidate) | 0.5 | 灰色 | 是 |
| 選中格 (Selected) | 0.6 | 暗金色 | 否 |
| Hover | 0.7 | 灰色 | 是 |
const COLOR_SELECTED = new Color3(0.4, 0.3, 0.1)
const COLOR_CANDIDATE = new Color3(0.3, 0.3, 0.3)
const ALPHA_SELECTED = 0.6
const ALPHA_CANDIDATE = 0.5
const ALPHA_HOVER = 0.7
const ALPHA_HIDDEN = 0.0
const FADE_SPEED = 14平滑過渡
狀態切換使用指數平滑做漸變動畫:
scene.onBeforeRenderObservable.add(() => {
const dt = engine.getDeltaTime() / 1000
const t = 1 - Math.exp(-FADE_SPEED * dt)
for (const [key, material] of tileMaterialMap) {
const targetAlpha = targetTileAlphaMap.get(key) ?? ALPHA_HIDDEN
const targetColor = targetTileColorMap.get(key)
// 透明度漸變
material.alpha = material.alpha + (targetAlpha - material.alpha) * t
// 顏色漸變
if (targetColor) {
material.emissiveColor.r
+= (targetColor.r - material.emissiveColor.r) * t
material.emissiveColor.g
+= (targetColor.g - material.emissiveColor.g) * t
material.emissiveColor.b
+= (targetColor.b - material.emissiveColor.b) * t
material.diffuseColor.copyFrom(material.emissiveColor)
}
}
})1 - Math.exp(-FADE_SPEED * dt) 是一種與幀率無關的指數平滑公式。不管 60fps 還是 144fps,動畫速度都一致。( •̀ ω •́ )✧
候選格同步
每次放置或移除積木後,需要重新計算哪些格子是候選格。
function syncAllCandidateTile() {
// 移除沒有相鄰 placed block 的孤立候補格
candidateTileMap.forEach((hex, key) => {
const hasAdjacentBlock = Array.from({ length: 6 }, (_, d) =>
hex.neighbor(d),).some((neighbor) => props.placedBlockMap.has(neighbor.key()))
const hasSelfBlock = props.placedBlockMap.has(key)
if (hasAdjacentBlock || hasSelfBlock)
return
removeCandidate(hex)
})
// 補上 placedBlock 鄰格中缺少的候補
props.placedBlockMap.forEach((block) => {
for (let d = 0; d < 6; d++) {
addCandidate(block.hex.neighbor(d))
}
})
// 若地圖為空,確保原點候補存在
if (props.placedBlockMap.size === 0) {
candidateTileMap.forEach((hex) => removeCandidate(hex))
addCandidate(new Hex(0, 0, 0))
}
}另外 addCandidate 會檢查 hex.len() > MAX_RADIUS,限制最大放置半徑為 2 格。
積木放置與動畫
放置積木時使用彈性彈跳動畫,讓積木從下方彈出:
content\aquarium\hexazen\domains\block\builder\index.ts
// 進入動畫
rootNode.position.y -= Z_OFFSET
await animate(rootNode.position, {
y: 0,
duration: 1000,
ease: 'outElastic(1,0.52)',
}).then()移除時則反向沉入地面:
async function dispose() {
await animate(rootNode.position, {
y: -Z_OFFSET,
duration: 600,
ease: 'inBack',
}).then()
rootNode.dispose()
smoothParticleSystem?.dispose()
scope.stop()
}outElastic 讓積木「咚」一聲彈出來,inBack 則讓積木稍微往上抬再沉下去,提供回饋感。(・∀・)9
旋轉互動
點擊已放置的積木可以旋轉 60°,搭配彈跳效果:
// 旋轉
animatingBlockSet.add(pickedKey)
const duration = 800
animate(block.rootNode.rotation, {
y: block.rootNode.rotation.y + Math.PI / 3,
duration,
ease: 'inOutCirc',
})
animate(block.rootNode.position, {
y: [0, 0.2, 0],
duration,
ease: 'inOutBack(3)',
onComplete() {
animatingBlockSet.delete(pickedKey)
},
})旋轉的同時積木會稍微抬起再放下,animatingBlockSet 則防止動畫期間重複觸發。
滑鼠偵測與 Ray-casting
Babylon.js 的 scene.pick 方法透過射線投射來判斷滑鼠點到了哪個 mesh。
我們需要兩組偵測器:一組用於已放置的積木(旋轉/移除),一組用於候選格(放置)。
// 偵測已放置的積木
scene.onPointerObservable.add(async (info) => {
const pick = scene.pick(scene.pointerX, scene.pointerY, (mesh) => {
const key = hexMeshMetadata(mesh)?.hex.key()
return key ? props.placedBlockMap.has(key) : false
})
// ... 處理旋轉或移除
})
// 偵測候選格
scene.onPointerObservable.add((info) => {
const pick = scene.pick(scene.pointerX, scene.pointerY, (mesh) => {
const key = hexMeshMetadata(mesh)?.hex.key()
if (!key || props.placedBlockMap.has(key))
return false
return candidateTileMap.has(key) || selectedTileSet.has(key)
})
// ... 處理 hover 和選取
})透過 pick 的 filter callback 可以精確控制哪些 mesh 會被偵測到。
後製效果管線
讓畫面更有質感的最後一步:
const pipeline = new DefaultRenderingPipeline('hexazenPipeline', true, scene, [
camera,
])
pipeline.fxaaEnabled = true
pipeline.samples = 4
// 景深
pipeline.depthOfFieldEnabled = true
pipeline.depthOfFieldBlurLevel = DepthOfFieldEffectBlurLevel.High
pipeline.depthOfField.focalLength = 135
pipeline.depthOfField.fStop = DEFAULT_F_STOP
// 色調
pipeline.imageProcessingEnabled = true
pipeline.imageProcessing.contrast = 1.1
pipeline.imageProcessing.exposure = 1.1
// 暗角(vignette)
pipeline.imageProcessing.vignetteEnabled = true
pipeline.imageProcessing.vignetteWeight = DEFAULT_VIGNETTE_WEIGHT景深的對焦距離會自動跟隨攝影機:
scene.onBeforeRenderObservable.add(() => {
if (pipeline.depthOfFieldEnabled && camera instanceof ArcRotateCamera) {
pipeline.depthOfField.focusDistance = camera.radius * 1000
}
})暗角顏色隨主題變化
更有趣的是,暗角的顏色會根據場景中最多的特性類型動態變化:
const traitVignetteColorMap: Record<TraitType, Color3> = {
grass: new Color3(0, 0.2, 0),
tree: new Color3(0, 0.2, 0),
building: new Color3(0.4, 0.2, 0),
alpine: new Color3(0, 0, 0),
river: new Color3(0, 0.4, 0.4),
water: new Color3(0, 0.4, 0.5),
// ...
}放滿樹木時暗角泛綠,放滿水域時暗角泛藍,整體視覺氣氛會跟著改變。◝( •ω• )◟
總結 🐟
以上程式碼可以在此取得
- PBR 材質 + 鏡面反射打造質感地板
- 三態候選格搭配指數平滑漸變動畫
- 彈性進出動畫讓積木操作充滿回饋感
- Ray-casting 精確偵測滑鼠互動
- 後製效果管線 + 動態暗角色彩提升視覺氣氛
下一章讓我們來實作天氣系統與粒子特效吧。♪( ◜ω◝و(و
TIP
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥