👩‍🎨

Three.js + glTF + Spine のテンプレートです

2024/08/18に公開

表題の通り、Vite をベースに Three.js + glTF + Spine のテンプレートを作成してみました。(Pixi.js のテンプレートに同じく自分用です。)
Three.js で遊びたいときに便利なテンプレートになっています。

リポジトリ・サンプルページ

> リポジトリ:t-tonyo-maru/pub_template_web_threejs-gltf-spine
> サンプルページ

サンプルページ スクリーンショット
サンプルページ スクリーンショット

開発環境

開発環境 バージョン
node >=20

主なパッケージ

パッケージ バージョン
dotenv ^16.4.5
typescript ^5.2.2
vite ^5.2.0
vitest ^1.6.0
@types/three ^0.167.1
three ^0.167.1
@esotericsoftware/spine-threejs ^4.2.58
lil-gui ^0.19.2

three や @esotericsoftware/spine-threejs の他に、dotenv や vitest や lil-gui なども入れています。
それぞれの用途は Pixi.js のテンプレート と同じです。

また、Spine は v4.2 を利用しており、エクスポートデータも v4.2 です。

ディレクトリ構成

/
├── .env.sample
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── assets … 静的ファイルの格納先
│       ├── images
│       ├── models … glTF のサンプルを格納しています
│       │   └── boombox.glb
│       └── spines … Spine エクスポートデータのサンプルを格納しています
│           ├── model.atlas
│           ├── model.json
│           └── model.png
├── src
│   ├── main.ts … 主な処理はココに書いています。
│   ├── reset.css
│   ├── utils
│   └── vite-env.d.ts
├── tsconfig.json
├── vite.config.ts
└── vitest.config.ts

main.ts の解説

主要な処理は src/main.ts に記述しています。

src/main.ts
// 各種パッケージの読み込み
import './reset.css'
import * as THREE from 'three'
import * as Spine from '@esotericsoftware/spine-threejs'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { GUI } from 'lil-gui'
import { setResizeEvent } from '~/utils/resizeWindow/resizeWindow'

// Github Pages のパスを読み込む
const VITE_GITHUB_PAGES_PATH =
  import.meta.env.BASE_URL !== '/' ? `${import.meta.env.BASE_URL}` : ''
const ASSETS_PATH = `${VITE_GITHUB_PAGES_PATH}/assets`

const width = window.innerWidth || 800
const height = window.innerHeight || 600
const devicePixelRatio = window.devicePixelRatio || 1

// レンダラー
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: true
})
renderer.setPixelRatio(devicePixelRatio)
renderer.setSize(width, height)
renderer.shadowMap.enabled = true

// lil-gui: コントロールパネル
const gui = new GUI()

// シーン
const scene = new THREE.Scene()

// カメラ
const camera = new THREE.PerspectiveCamera(45, width / height, 1, 10000)
camera.position.set(10, 10, 10)
camera.lookAt(new THREE.Vector3(0, 0, 0))

// 地面
const planeGeometry = new THREE.PlaneGeometry(16, 16)
const planeMaterial = new THREE.MeshStandardMaterial({ color: 0x213573 })
const plane = new THREE.Mesh(planeGeometry, planeMaterial)
plane.rotation.x = -Math.PI / 2
plane.material.side = THREE.DoubleSide // 両面を表示する
plane.receiveShadow = true // 3Dオブジェクトの影を落とせるようにする
scene.add(plane)

// 平行光源
const directionalLight = new THREE.DirectionalLight(0xffffff)
directionalLight.position.set(1, 1, 1)
scene.add(directionalLight)

// 環境光源
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0)
scene.add(ambientLight)

// スポットライト
const spotLight = new THREE.SpotLight(0xffffff, 24, 12, Math.PI / 4, 10, 0.5)
spotLight.position.set(0, 8, 0)
spotLight.castShadow = true
spotLight.shadow.mapSize.set(4096, 4096)
scene.add(spotLight)
// スポットライト ヘルパー
const spotLightHepler = new THREE.SpotLightHelper(spotLight)
scene.add(spotLightHepler)
// スポットライト lil-gui
const spotLightFolder = gui.addFolder('SpotLight')
// …略…

// サンプルテクスチャ
const textureloader = new THREE.TextureLoader()
const sampleTexture = textureloader.load(`${ASSETS_PATH}/models/texture.jpg`)

// サンプル立方体
const boxGeometry = new THREE.BoxGeometry(3, 3, 3)
const boxMaterial = new THREE.MeshStandardMaterial({
  map: sampleTexture,
  metalness: 0.75,
  roughness: 0
})
const box = new THREE.Mesh(boxGeometry, boxMaterial)
box.position.set(0, 1.5, 0)
box.castShadow = true
scene.add(box)

// gltf オブジェクト
let gltfObject: GLTF
const gltfLoader = new GLTFLoader()
gltfLoader.load(`${ASSETS_PATH}/models/boombox.glb`, (data) => {
  gltfObject = data
  gltfObject.scene.traverse((child) => {
    child.castShadow = true
  })
  gltfObject.scene.scale.set(100, 100, 100)
  gltfObject.scene.position.set(-4, 1, 0)

  scene.add(gltfObject.scene)

  const gltfObjectHelper = new THREE.BoxHelper(gltfObject.scene, 0xffff00)
  scene.add(gltfObjectHelper)
})

// Spine
let isAddedSpine = false
let spineSkeletonMesh: Spine.SkeletonMesh
const spineWrapperGeometry = new THREE.BoxGeometry(5, 5, 0.1)
const spineWrapperMaterial = new THREE.MeshStandardMaterial({
  wireframe: true
})
const spineWrapperMesh = new THREE.Mesh(
  spineWrapperGeometry,
  spineWrapperMaterial
)
spineWrapperMesh.position.set(5.5, 2.5, 0)
scene.add(spineWrapperMesh)

// Spine Asset Manager
const assetManager = new Spine.AssetManager(`${ASSETS_PATH}/spines/`)
assetManager.loadText('model.json')
assetManager.loadTextureAtlas('model.atlas')

const spineFolder = gui.addFolder('SpineFolder')
spineFolder.add({ wind: 0 }, 'wind', -20, 20, 0.1).onChange((value: number) => {
  if (!spineSkeletonMesh) return
  // 物理コンストレイントの更新
  spineSkeletonMesh.skeleton.physicsConstraints.map((constraint) => {
    constraint.wind = value
  })
})

// OrbitController
const orbitController = new OrbitControls(camera, renderer.domElement)
orbitController.maxPolarAngle = Math.PI // Math.PI * 0.5
orbitController.minDistance = 0.1
orbitController.maxDistance = 10000
orbitController.autoRotateSpeed = 1.0

// div 要素に追加
const wrapper = document.querySelector<HTMLDivElement>('#app')!
wrapper.appendChild(renderer.domElement)

// window リサイズイベント
setResizeEvent({
  onResize: () => {
    const width = window.innerWidth
    const height = window.innerHeight
    // レンダラーを更新
    renderer.setPixelRatio(window.devicePixelRatio || 1)
    renderer.setSize(width, height)
    // カメラを更新
    camera.aspect = width / height
    camera.updateProjectionMatrix()
  }
})

// 画面に表示+アニメーション
let lastFrameTime = Date.now() / 1000
const ticker = () => {
  requestAnimationFrame(ticker)

  // 時刻更新
  const now = Date.now() / 1000
  const delta = now - lastFrameTime
  lastFrameTime = now

  // Spine Asset 読み込み後の処理
  if (assetManager.isLoadingComplete()) {
    if (!isAddedSpine) {
      const atlas: Spine.TextureAtlas = assetManager.require('model.atlas')
      const atlasLoader = new Spine.AtlasAttachmentLoader(atlas)
      const skeletonJson = new Spine.SkeletonJson(atlasLoader)
      const skeletonData = skeletonJson.readSkeletonData(
        assetManager.require('model.json')
      )
      // spine mesh
      spineSkeletonMesh = new Spine.SkeletonMesh(
        skeletonData,
        (parameters: THREE.ShaderMaterialParameters) => {
          // Spine 公式の example では parameters.depthWrite = true としていますが
          // チラつきが発生してしまうため、parameters.depthWrite = false としています
          parameters.depthWrite = false
          parameters.depthTest = true
          parameters.alphaTest = 0.001
        }
      )

      // spine skeleton のサイズ
      const skeletonWidth = skeletonData.width
      const skeletonHeight = skeletonData.height
      const skeletonAspectRatio = skeletonWidth / skeletonHeight
      // (ラッパー Mesh のサイズ / spine skeleton のサイズ) * Spine データのアスペクト比 …を計算して、ラッパー Mesh 内に収めています
      spineSkeletonMesh.scale.set(
        (5 / skeletonWidth) * skeletonAspectRatio,
        5 / skeletonHeight,
        0
      )
      spineSkeletonMesh.position.set(0, 5 * -0.5, 0)
      spineSkeletonMesh.state.setAnimation(0, 'animation', true)
      spineWrapperMesh.add(spineSkeletonMesh)
    }
    isAddedSpine = true
  }

  // サンプル立方体の回転
  box.rotation.x += 0.01
  box.rotation.y += 0.01
  box.rotation.z += 0.01

  // Spine Skeleton 更新
  // Spine の SkeletonMesh は update() を呼び出さないとアニメーションが更新されないので注意!
  if (spineSkeletonMesh) {
    spineSkeletonMesh.update(delta)
  }
  // ヘルパー更新
  spotLightHepler.update()
  // OrbitController更新
  orbitController.update()

  renderer.render(scene, camera)
}
ticker()

参考ページ

本テンプレートを作成するにあたり、下記のページ・プロジェクトを参考にしました。

まとめ

Three.js でもいい感じで Spine を表示することができました。
GLTFLoader を使って glTF を読み込むこともできたので、やろうと思えば2.5Dゲームのような画面デザインを作成できます。
これから機会を見つけて、使っていきたいと思います〜!

Discussion