👩🎨
Three.js + glTF + Spine のテンプレートです
表題の通り、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 x Spine
- GLTFLoader | Three.js
まとめ
Three.js でもいい感じで Spine を表示することができました。
GLTFLoader を使って glTF を読み込むこともできたので、やろうと思えば2.5Dゲームのような画面デザインを作成できます。
これから機会を見つけて、使っていきたいと思います〜!
Discussion