---
url: /column-hexazen/04-3d-scene-interaction.md
description: 讓積木可以放置、旋轉、移除，還有滑順的動畫與視覺效果。◝(≧∀≦)◟
---

![hexazen-cover](/hexazen-cover.webp){.cover}

# HexaZen - EP04：3D 場景互動

有了六角格和音效系統，現在要讓使用者能在 3D 場景中實際操作積木。

點擊空格放置、點擊積木旋轉、切換鏟子模式移除。%੭ ˙ᗜ˙ )੭%

## 地板：PBR 材質與鏡面反射

首先讓地板看起來更有質感。使用 PBR 材質加上 `MirrorTexture` 產生鏡面倒影。

```typescript
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    | 灰色   | 是     |

```typescript
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
```

### 平滑過渡

狀態切換使用**指數平滑**做漸變動畫：

```typescript
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，動畫速度都一致。%( •̀ ω •́ )✧%

## 候選格同步

每次放置或移除積木後，需要重新計算哪些格子是候選格。

```typescript
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`

```typescript
// 進入動畫
rootNode.position.y -= Z_OFFSET
await animate(rootNode.position, {
  y: 0,
  duration: 1000,
  ease: 'outElastic(1,0.52)',
}).then()
```

移除時則反向沉入地面：

```typescript
async function dispose() {
  await animate(rootNode.position, {
    y: -Z_OFFSET,
    duration: 600,
    ease: 'inBack',
  }).then()

  rootNode.dispose()
  smoothParticleSystem?.dispose()
  scope.stop()
}
```

`outElastic` 讓積木「咚」一聲彈出來，`inBack` 則讓積木稍微往上抬再沉下去，提供回饋感。%(・∀・)９%

## 旋轉互動

點擊已放置的積木可以旋轉 60°，搭配彈跳效果：

```typescript
// 旋轉
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。

我們需要兩組偵測器：一組用於已放置的積木（旋轉/移除），一組用於候選格（放置）。

```typescript
// 偵測已放置的積木
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 會被偵測到。

## 後製效果管線

讓畫面更有質感的最後一步：

```typescript
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
```

景深的對焦距離會自動跟隨攝影機：

```typescript
scene.onBeforeRenderObservable.add(() => {
  if (pipeline.depthOfFieldEnabled && camera instanceof ArcRotateCamera) {
    pipeline.depthOfField.focusDistance = camera.radius * 1000
  }
})
```

### 暗角顏色隨主題變化

更有趣的是，暗角的顏色會根據場景中最多的特性類型動態變化：

```typescript
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),
  // ...
}
```

放滿樹木時暗角泛綠，放滿水域時暗角泛藍，整體視覺氣氛會跟著改變。%◝( •ω• )◟%

## 總結 🐟

以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen/main-scene.vue)

* PBR 材質 + 鏡面反射打造質感地板
* 三態候選格搭配指數平滑漸變動畫
* 彈性進出動畫讓積木操作充滿回饋感
* Ray-casting 精確偵測滑鼠互動
* 後製效果管線 + 動態暗角色彩提升視覺氣氛

下一章讓我們來實作天氣系統與粒子特效吧。%♪( ◜ω◝و(و%

::: tip
因為篇幅問題，某些部分簡單帶過。

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