【Three.js x ハイポリモデル】パフォーマンスで気をつけたこと

に公開

Blender から書き出した3Dモデルを Three.js で実装する環境で、パフォーマンス面で気をつけたことをまとめていきます。

主に、renderer.setPixelRatio と FPS のコントロールで、重い問題がかなり改善されました。調整無しの状態だと、デバイス本来のFPSから40%減(60FPS -> 36FPS)くらいだったのですが、調整することで、本来のFPSに近づくくらいには安定しました。

制作ページのまとめは下記の記事に記載しています。

https://zenn.dev/fabrica/articles/a4177b5cf72658

制作ページ

https://projects-car-show.vercel.app/?91add47f1f72e0

renderer.setPixelRatio の設定値をコントロール

前提と周辺知識

renderer.setPixelRatio() とは、レンダラーが描画する実ピクセル数を制御するもので、指定する値を調整すればパフォーマンスと画質に影響を及ぼします。

例えば、下記のように指定すると、

// canvas.cientWidth: 1440 canvas.clientHeight: 800 と仮定
renderer.setSize(canvas.cientWidth, canvas.clientHeight)
renderer.setPixelRatio(2)

width: 2880 height: 1600 の密度で描画するということになります。

それから、renderer.setPixelRatio() には window.devicePixelRatio を指定するのがよく見る実装です。

window.devicePixelRatio は、CSSピクセルと実際の物理ピクセルの比率を示す値で、高解像度ディスプレイでは値が大きくなり、1や2などの値を取ります。

パフォーマンスが気になる場合は、window.devicePixelRatio に設定する値を少し下げると少しでも改善できるので、それを踏まえて下記の調整をしてみました。

設定値の調整

  1. 希望の比率と最低限の比率を設定
  2. スクリーンサイズが大きすぎる場合の調整
/**
 * 型定義
 */
type PixelRatioManager = (
  options: {
    wishPixelRatioPercent: number,
    minPixelRatio: number,
    baseSize: {
      width: number,
      height: number,
    },
  },
) => {
  getPixelRatio: () => number,
  setPixelRatio: (
    renderer: THREE.WebGLRenderer,
  ) => void,
} | null

/**
 * Three.jsのレンダラーにピクセル比率を適用
 */
export const pixelRatioManager: PixelRatioManager = (
  options,
) => {
  if (typeof window === 'undefined') return null

  let pixelRatio = 0

  // 希望の割合を参考にピクセル比率を設定
  const wishPixelRatio = window.devicePixelRatio * options.wishPixelRatioPercent)

  // デバイスのピクセル比率の最適化
  const devicePixelRatio = fixPixelRatioDevice(wishPixelRatio, options.baseSize)

  // 最低限の PixelRatio を保証
  pixelRatio = Math.max(devicePixelRatio, options.minPixelRatio)

  return {
    getPixelRatio: () => {
      return pixelRatio
    },
    setPixelRatio: (
      renderer,
    ) => {
      renderer.setPixelRatio(pixelRatio)
    },
  }
}

/**
 * 【デバイスのピクセル比率の最適化】
 * @param pixelRatio ピクセル比率
 * @param baseSize 基準サイズ
 * @returns 適用するピクセル比率
 */
export const fixPixelRatioDevice = (
  pixelRatio: number,
  baseSize: {
    width: number,
    height: number,
  }
) => {
  const baseArea = baseSize.width * baseSize.height
  const actualArea = canvas.clientWidth * canvas.clientHeight
  const scaling = Math.sqrt(baseArea / actualArea)

  if (scaling > 1) {
    // baseSize よりも小さい場合はピクセル比率を固定
    return pixelRatio
  } else {
    // baseSize よりも大きい場合はピクセル比率を最適化
    return pixelRatio * scaling
  }
}
// 設定値
const options = {
  wishPixelRatioPercent: 0.8, // 0 〜 1
  mobileWishPixelRatioPercent: 0.65, // 0 〜 1
  minPixelRatio: 1,
  baseSize: {
    width: 1920,
    height: 1080,
  },
}

// 使用例
pixelRatioManager(
  options,
)?.setPixelRatio(renderer)

wishPixelRatioPercent に少し下げたい割合を指定して、元々の window.devicePixelRatio が 1 の場合に極端に劣化しないように minPixelRatio で調整できるようにしています。

ちなみに上記はPC端末の場合で、SPの場合は個別で割合を設定しています。

fixPixelRatioDevice は一般的なPCのパフォーマンスで大きなモニターに繋いだ時に、描画する実質ピクセルがかなり大きくなると重たくなったので、基準サイズ( baseSize )を元にスケーリングさせています。

でも、そもそもの canvas サイズの最大値を決めて小さくしておくのも良いと思います。

FPSのコントロール

Three.js だとレンダリングは requestAnimationFramerenderer.setAnimationLoop を使うと思いますが、デバイスのFPSに従って毎秒60回ほどレンダリングするとGPUの負荷が大きくなります。

意図的にレンダリングする回数を抑えることで安定することもあり、今回の実装だと30FPSを目指して調整することで、かなり軽くなりました。

調整の方針

  • 毎秒約30回レンダリングするように判定する
  • タイムスタンプを使ってFPSを測定して判定に使う
/**
 * 【FPS管理とレンダリング回数を抑制】
 * @param targetFps 目標FPS
 * @param options オプション
 * @returns FPSマネージャー
 */
export const setFpsManager: SetFpsManager = (
  targetFps,
  options,
) => {
  const status = {
    fps: 0,
    fixedFps: 0,
  }

  const counter: FpsCounter = {
    rawFrameCount: 0,
    adjustedFrameCount: 0,
    lastTimestamp: 0,
    lastRenderTime: 0,
    targetInterval: 1000 / targetFps,
  }

  /**
   * FPS計測
   */
  const measure = (
    counter: FpsCounter,
    timestamp: number,
  ) => {
    if (timestamp - counter.lastTimestamp >= 1000) {
      if (options?.log) console.log(`📈 実測FPS: ${counter.rawFrameCount}, 調整後FPS: ${counter.adjustedFrameCount}`)
      counter.rawFrameCount = 0
      counter.adjustedFrameCount = 0
      counter.lastTimestamp = timestamp
    }
  }

  /**
   * ループ中に走らせる処理
   */
  const rendering = (
    timestamp: number,
    process: () => void,
  ) => {
    // 実際のFPS計測
    counter.rawFrameCount++

    // 前回のレンダリング時間からの経過時間を計算
    const elapsed = timestamp - counter.lastRenderTime

    // 経過時間が目標間隔より短い場合はレンダリングをスキップ
    if (elapsed < counter.targetInterval) return

    // 前回のレンダリング時間を更新
    counter.lastRenderTime = timestamp

    // 調整後のFPS計測
    counter.adjustedFrameCount++

    // FPS計測
    measure(counter, timestamp)

    // レンダリング処理を実行
    process()
  }

  return {
    status,
    rendering,
  }
}

活用例

/**
 * FPSマネージャー
 */
const fpsManager = setFpsManager(
  setupMember.renderer.targetFps,
  {
    log: setupMember.renderer.fpsLog,
  },
)

/**
 * アニメーション
 */
function animate(
  timestamp: number,
) {
  // renderProcess: 実際のレンダリング処理が入る想定
  fpsManager.rendering(timestamp, renderProcess)
}
renderer.setAnimationLoop(animate)

調整しない状態だと、デバイス本来のFPSから40%減くらいになっていたのですが、調整することで本来のFPSに安定するようになりました。

でも、端末によってカクつきが出ることがあったり、上記のソースも完全に正確にFPS計測できている訳じゃないので、まだまだ調査・改良中です。

鏡面反射の調整

平面ミラー反射を実装するための Three.js ヘルパークラスである Reflector を使うと、反射用カメラによるレンダリングが余分に必要になってパフォーマンスに影響が出ます。

https://sbcode.net/threejs/reflector/


実装イメージ

/**
 * Reflector を使ったミラー素材の地面
 */
export const getReflector = (
  renderer: THREE.WebGLRenderer,
  geometry: THREE.PlaneGeometry | THREE.CircleGeometry,
): THREE.Mesh => {
  const groundMirror = new Reflector(geometry, {
    clipBias: 0.01,
    textureWidth: window.innerWidth * texturePixelRatio,
    textureHeight: window.innerHeight * texturePixelRatio,
    color: 0xA8B0E7,
  })

  return groundMirror
}

ミラーとして扱うテクスチャのサイズを調整すると、その分パフォーマンスも良くなります。また、平面ミラーの上に透過した別のテクスチャを重ねると、少し荒くなっても誤魔化しが効くのと見栄えも良くなります。

調整値である texturePixelRatio は、下記のように決めました。

  const pixelRatio = pixelRatioManager(
    renderer.domElement,
    setupMember.renderer.pixelRatio,
  )?.getPixelRatio()

  const texturePixelRatio = Math.max((pixelRatio ?? 1) / 3, setupMember.renderer.pixelRatio.groundTextureMinPixelRatio)
  // setupMember.renderer.pixelRatio.groundTextureMinPixelRatio = 0.7 ほど  
  console.log('[Ground] texturePixelRatio:', texturePixelRatio)

初めにまとめた pixelRatioManager で設定した値を使いつつ、サイズダウンさせる値を掛け合わせています。

Fabrica.テックブログ

Discussion