Skip to content

hexazen-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暗金色
Hover0.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 則讓積木稍微往上抬再沉下去,提供回饋感。(・∀・)9

旋轉互動

點擊已放置的積木可以旋轉 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),
  // ...
}

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

總結 🐟

以上程式碼可以在此取得

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

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

TIP

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

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