3️⃣

Nuxt3でthree.jsを使いたい!

2022/07/21に公開

はじめに

最近Nuxt3とthree.jsについて学んでいて、両方いっぺんに学びたい!と思いこの記事を書きました。

この記事のゴール

Nuxt3とthree.jsを使ってSPAで3Dオブジェクトを描画する。

バージョン

node -> v16.11.0
Nuxt -> v3.0.0-rc.6
three.js -> v^0.142.0

サンプル

https://three-nuxt3.web.app/

ソースコード

https://github.com/tsukiyama-3/three-nuxt3

環境構築

Nuxt3 インストール

npx nuxi init three-nuxt3
cd three-nuxt3
npm i
npm run dev

http://localhost:3000/でページが表示されれば完了。

three.js インストール

npm i --save three
npm i @types/three

Windi CSS インストール (しなくても良い)

Windi CSS を使用しない方はスキップしてください。

npm i nuxt-windicss -D

nuxt.config.ts 修正

nuxt-windicssを追加

nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'

// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
+  buildModules: [
+    'nuxt-windicss',
+  ],
})

実装

今回は2ページ作成してSPAでページ遷移できるようにしました。

app.vue

app.vue
+ <script setup lang="ts">
+ useWindowSize()
+ </script>
+ 
<template>
+ <div id="app" class="fixed top-0 left-0 w-full h-full">
+   <NuxtPage />
  </div>
</template>

composables/window-size.ts

新規作成
画面サイズの状態管理

composables/window-size.ts
import { Ref } from 'vue'

const clientWidth: Ref<number> = ref(0)
const clientHeight: Ref<number> = ref(0)
const targetDom: Ref<HTMLElement | null> = ref(null)

export const useWindowSize = () => {
  onMounted(() => {
    targetDom.value = document.getElementById('app')
    clientWidth.value = targetDom.value.clientWidth
    clientHeight.value = targetDom.value.clientHeight
    window.addEventListener('resize', updateWindowSize)
  })
  const updateWindowSize = () => {
    clientWidth.value = targetDom.value.clientWidth
    clientHeight.value = targetDom.value.clientHeight
  }
  return {
    clientWidth,
    clientHeight
  }
}

pages/index.vue

新規作成

pages/index.vue
<script setup lang="ts">
import { Ref } from 'vue'

const container: Ref<HTMLElement | null> = ref(null)
const { clientWidth, clientHeight } = useWindowSize()

onMounted(() => {
  const { init } = useSphere(container, clientWidth, clientHeight)
  init()
})
</script>

<template>
  <div class="fixed top-0 left-0 w-full h-full" ref="container">
    <div class="absolute top-0 left-0">
      <NuxtLink to="box" class="block py-4 px-12 bg-white font-bold hover:underline">Box ></NuxtLink>
    </div>
  </div>
</template>

composables/sphere.ts

新規作成

sphere.ts
import { Line, LineBasicMaterial, PerspectiveCamera, Scene, SphereGeometry, Vector3, WebGLRenderer } from 'three'

import { Ref } from 'vue'

export const useSphere = (container: Ref<HTMLElement>, clientWidth: Ref<number>, clientHeight: Ref<number>) => {
  const init = () => {
    // レンダラー作成
    const renderer = new WebGLRenderer()
    renderer.setSize(clientWidth.value, clientHeight.value)
    renderer.setPixelRatio(clientWidth.value / clientHeight.value)
    container.value.appendChild(renderer.domElement)
    // シーン追加
    const scene = new Scene()
    // カメラ作成
    const camera = new PerspectiveCamera(45, clientWidth.value / clientHeight.value)
    camera.position.set(20, 20, 20)
    camera.lookAt(new Vector3(0, 0, 0))
    // 球体作成
    const geometry = new SphereGeometry(10, 32, 32)
    const material = new LineBasicMaterial({ color: 0x6699ff, linewidth: 1 })
    const sphere = new Line(geometry, material)
    scene.add(sphere)
    // 毎フレーム時に実行されるループイベント
    const tick = () => {
      // 球体を回転
      sphere.rotation.x += .01
      sphere.rotation.y += .01
      // レンダリング
      renderer.render(scene, camera)
      requestAnimationFrame(tick)
    }
    tick()
    // コンテキスト削除
    onUnmounted(() => {
      renderer.dispose()
      renderer.forceContextLoss()
    })
  }
  return { init }
}

pages/box.vue

新規作成

pages/box.vue
<script setup lang="ts">
import { Ref } from 'vue'
const container: Ref<HTMLElement | null> = ref(null)
const { clientWidth, clientHeight } = useWindowSize()
onMounted(() => {
  const { init } = useBox(container, clientWidth, clientHeight)
  init()
})
</script>

<template>
  <div class="fixed top-0 left-0 w-full h-full" ref="container">
    <div class="absolute top-0 left-0">
      <NuxtLink to="/" class="block py-4 px-12 bg-white font-bold hover:underline">Sphere ></NuxtLink>
    </div>
  </div>
</template>

composabels/box.ts

composables/box.ts
import { BoxGeometry, Mesh, MeshNormalMaterial, PerspectiveCamera, Scene, Vector3, WebGLRenderer } from 'three'
import { Ref } from 'vue'

export const useBox = (container: Ref<HTMLElement>, clientWidth: Ref<number>, clientHeight: Ref<number>) => {
  const init = () => { 
    // レンダラー作成
    const renderer = new WebGLRenderer()
    renderer.setSize(clientWidth.value, clientHeight.value)
    renderer.setPixelRatio(clientWidth.value / clientHeight.value)
    container.value.appendChild(renderer.domElement)
    // シーン作成
    const scene = new Scene()
    // カメラ作成
    const camera = new PerspectiveCamera(45, clientWidth.value / clientHeight.value)
    camera.position.set(20, 20, 20)
    camera.lookAt(new Vector3(0, 0, 0))
    // 箱作成
    const geometry = new BoxGeometry(10, 10, 10)
    const material = new MeshNormalMaterial()
    const box = new Mesh(geometry, material)
    scene.add(box)
    // 毎フレーム時に実行されるループイベント
    const tick = () => {
      // 箱を回転
      box.rotation.x += .01
      box.rotation.y += .01
      // レンダリング
      renderer.render(scene, camera)
      requestAnimationFrame(tick)
    }
    tick()
    // コンテキスト削除
    onUnmounted(() => {
      renderer.dispose()
      renderer.forceContextLoss()
    })
  }
  return { init }
}

コード上で行っていることはコメントとして入れておきました。

詳しく知りたい方は公式ドキュメントを参照してください。
Nuxt3
three.js

詰まった箇所

作ってみて詰まった箇所を共有します。

SPAで遷移先で描画されていない

3DオブジェクトをonMounted時にdomに描画しているのですが、ページ遷移すると3Dオブジェクトが描画されない問題。
ページ遷移先の画面サイズが取得できていないのが原因っぽい。

解決方法

composabels/window-size.tsで画面サイズを状態管理して解決しました。
(多分もっと良い方法があると思う)

ページ遷移しすぎるとErrorが出る

three.module.js:26455 WARNING: Too many active WebGL contexts. Oldest context will be lost.
最大で15個のWebGLコンテキストが有効でそれを超えるとErrorが出る。

解決方法

// コンテキスト削除
onUnmounted(() => {
  renderer.dispose()
  renderer.forceContextLoss()
})

onUnmounted時にコンテキストを削除したら解決しました。
ただ、ページ遷移時に一瞬だけ画面が白くなってしまうのでそれもなんとかしたい。

さいごに

Nuxt3 + three.js で3Dオブジェクトを描画する方法と詰まった箇所をまとめました。
この記事に書いたコードは僕の試行錯誤の結果なのでより良いコードがある場合は教えてもらえるとありがたいです🙇‍♂️

参考文献

https://v3.nuxtjs.org//
https://threejs.org/

Discussion