Skip to content

hexazen-cover

HexaZen - EP06:積木選擇器與縮圖生成

HexaZen 有 30 多種積木,光靠文字名稱選擇太無趣了,這裡需要每個積木的 3D 預覽縮圖。

但 30 多張圖片手動截圖太累了,而且每次微調積木就要重做。...(›´ω`‹ )

來做個自動縮圖產生器吧!( ´ ▽ ` )ノ

多執行緒縮圖生成器

核心概念是建立多個隱藏的 canvas,每個 canvas 都有獨立的 Babylon.js 引擎,像產線一樣平行生成縮圖。

決定並行數

首先根據裝置性能決定要啟動幾個「產線」:

content\aquarium\hexazen\composables\use-thumbnail-generator.ts

typescript
function getOptimalConcurrency(): number {
  if (typeof window === 'undefined' || !window.navigator) {
    return 2
  }

  const cores = navigator.hardwareConcurrency || 4
  const memory = (navigator as any).deviceMemory || 4

  let optimal = 2
  if (cores >= 8 && memory >= 8) {
    optimal = 6
  }
  else if (cores >= 4 && memory >= 4) {
    optimal = 4
  }
  else {
    optimal = 2
  }

  return Math.min(optimal, 6)
}

8 核 + 8GB 記憶體以上開 6 個,4 核開 4 個,其他開 2 個。上限 6 個避免吃太多資源。

建立隱藏的產線

每個產線都是一個完整的 Babylon.js 場景:

typescript
function createGenerator() {
  const queue = new PQueue({ concurrency: 1 })

  const canvas = document.createElement('canvas')
  canvas.style.visibility = 'hidden'
  canvas.width = 128
  canvas.height = 128
  document.body.appendChild(canvas)

  const engine = new Engine(canvas, true)
  const scene = new Scene(engine)
  scene.clearColor = new Color4(0, 0, 0, 0) // 透明背景

  const camera = new ArcRotateCamera(
    'camera',
    Math.PI / 4,
    Math.PI / 4,
    5,
    Vector3.Zero(),
    scene,
  )
  camera.useFramingBehavior = true
  if (camera.framingBehavior) {
    camera.framingBehavior.mode = FramingBehavior.FitFrustumSidesMode
    camera.framingBehavior.radiusScale = 1
    camera.framingBehavior.framingTime = 0
  }

  const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene)
  // ...
}

128×128 的小 canvas,透明背景,FramingBehavior 會自動將模型置中並調整鏡頭距離。

縮圖生成流程

typescript
function generateThumbnail(
  blockType: BlockType,
  options?: { signal?: AbortSignal },
) {
  return queue.add(
    async () => {
      const blockData = blockDefinitions[blockType]

      // 載入所有部件
      const tasks = blockData.content.partList.map(async (part) => {
        const modelPath = `${blockData.content.rootFolderName}/${part.path}`
        const model = await LoadAssetContainerAsync(modelPath, scene)
        const root = model.meshes[0]

        if (root) {
          root.position.copyFrom(Vector3.FromArray(part.position))
          root.rotationQuaternion?.copyFrom(
            Quaternion.FromArray(part.rotationQuaternion),
          )
          root.scaling.copyFrom(Vector3.FromArray(part.scaling))
        }

        return model
      })

      const containerList = await Promise.all(tasks)

      // 清除上一個模型,載入新模型
      clearLastContainer()
      lastContainerList = containerList
      containerList.forEach((c) => c.addAllToScene())

      // 自動調整鏡頭框住所有 mesh
      const meshes = containerList
        .flatMap((c) => c.meshes)
        .filter((m) => m.getTotalVertices() > 0)
      camera.framingBehavior?.zoomOnMeshesHierarchy(meshes, true)

      // 渲染一幀並截圖
      engine.runRenderLoop(() => scene.render())
      await scene.whenReadyAsync(true)

      camera.beta = Math.PI / 4
      camera.alpha = Math.PI / 4

      const base64 = await Tools.CreateScreenshotAsync(engine, camera, {
        width: 128,
        height: 128,
      })
      engine.stopRenderLoop()

      const res = await fetch(base64)
      return await res.blob()
    },
    { signal },
  )
}

流程為:載入模型 → 自動框住 → 渲染 → 截圖 → 轉 Blob。

這裡使用 p-queue 套件解決 queue 實作,每個產線一次只處理一個任務(concurrency: 1),但多個產線可以同時工作。

Round-Robin 分配

任務以輪詢方式分配給產線:

typescript
function generateThumbnail(blockType: BlockType) {
  const generator = generatorList[index]
  index = (index + 1) % concurrency
  return generator.generateThumbnail(blockType)
}

IndexedDB 快取

生成縮圖很耗時,不能每次開頁面都重新產生。用 IndexedDB 快取起來:

content\aquarium\hexazen\domains\block\block-picker.vue

typescript
const blockThumbnailList = computedAsync(async () => {
  const tasks = blockTypeList.map(async (blockType) => {
    const key = `hexazen-${version}-block-thumbnail-${blockType}`

    // 先查快取
    const cache = await get(key)
    if (cache) {
      return {
        type: blockType,
        thumbnail: URL.createObjectURL(cache),
      }
    }

    // 快取沒有,即時生成
    const imgBlob = await generateThumbnail(blockType)
    await set(key, imgBlob)

    return {
      type: blockType,
      thumbnail: URL.createObjectURL(imgBlob),
    }
  })

  return Promise.all(tasks)
}, [])

快取的 key 包含 version,版本更新時自動失效重新生成。

離開頁面時也要記得清理 ObjectURL

typescript
onBeforeUnmount(() => {
  blockThumbnailList.value.forEach((item) => {
    URL.revokeObjectURL(item.thumbnail)
  })
})

積木選擇器 UI

有了縮圖,UI 就簡單了。使用輪播元件搭配倒角邊框風格:

vue
<template>
  <div class="chamfer-4 p-1 bg-gray-200">
    <div class="bg-white chamfer-3.5 p-3">
      <u-carousel v-slot="{ item }" :items="blockThumbnailList" drag-free>
        <div
          class="size-22 chamfer-3 p-0.5 bg-gray-100
                 cursor-pointer select-none"
          @click="handleClick(item.type)"
        >
          <img
            :src="item.thumbnail"
            class="border-none! bg-white chamfer-2.5"
          >
        </div>
      </u-carousel>
    </div>
  </div>
</template>

drag-free 讓使用者可以自由拖拽瀏覽,手機上也很順暢。◝(≧∀≦)◟

createSharedComposable 單例化

縮圖生成器在整個應用中只需要一個實例,使用 VueUse 的 createSharedComposable

typescript
export const useThumbnailGenerator = createSharedComposable(
  _useThumbnailGenerator,
)

不管幾個元件呼叫 useThumbnailGenerator(),都會拿到同一個實例,共享同一批產線。

總結 🐟

以上程式碼可以在此取得

  • 多執行緒無頭 Babylon.js 場景自動產生 3D 縮圖
  • 根據裝置性能動態調整並行數
  • IndexedDB 快取避免重複生成
  • createSharedComposable 確保全域單例
  • 輪播 UI 支援拖拽瀏覽

下一章讓我們來看分享編碼與 URL 壓縮的實作吧。♪( ◜ω◝و(و

TIP

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

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