ブログ
こんにちは!
キーボード沼で散財しすぎてカード明細を見るのが怖い、エンジニアの山口です
先日、BBブログを読んでいて興味深い記事が目に止まりました
これです!
豆じゃないじゃん!と思わず吹いてしまったのですが
よくよく見ると可愛らしく見えてきます。なにか無骨ながら語りかけてくるものがある、、
「私を使って、あそんでよ」
そう言ってるような、、
ということで、今回はこの3DモデルとThree.jsを使って、3D空間いっぱいにビットくんを遊ばせたいと思います
いくぞ!
目次
3Dモデルのデータを作る
Blenderから3Dモデルのデータを適切な形式に書き出します。
「ファイル>エクスポート>gltf2.0」を選択
「フォーマット」は「glTF Embedded(.gltf)」を選択し、あとはデフォルトの設定のままで進めます
これで3Dモデルの準備はOK!
webpack5で開発環境を整える
さくっと開発環境を紹介します
webpack5を使っていきます
新しいフォルダを作ってpackage.jsonを作成
npm init -y
必要なパッケージをインストール
npm install --save-dev @babel/preset-env autoprefixer babel-loader clean-webpack-plugin css-loader html-loader html-webpack-plugin image-webpack-loader mini-css-extract-plugin postcss postcss-loader pug-html-loader sass sass-loader style-loader three webpack webpack-cli webpack-dev-server
以下、ファイルの構成とディレクトリの構造です。
.
├── package-lock.json
├── package.json
├── src
│ ├── css
│ │ └── main.scss
│ ├── images
│ ├── js
│ │ └── index.js
│ ├── models
│ │ └── bitkun.gltf
│ └── templates
│ └── index.pug
└── webpack.config.js
webpack.config.jsを作成
const path = require('path');
const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
mode: 'development',
entry: {
'js/main': './src/js/index.js',
},
output: {
path: path.resolve(__dirname, './dist'),
filename: '[name].js',
publicPath: '/',
},
devServer: {
static: {
directory: path.resolve(__dirname, './dist'),
},
compress: true,
port: 9000,
},
module: {
rules: [
{
test: /\.js/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { 'targets': '> 0.25%, not dead' }],
],
},
},
],
},
{
test: /\.(css|sass|scss)/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
options: {
sourceMap: true,
importLoaders: 2,
},
},
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
['autoprefixer', {grid: true}],
],
},
},
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
},
},
],
},
{
test: /\.(glb|gltf)/,
type: 'asset/resource',
generator: {
filename: 'models/[name][ext]',
},
},
{
test: /\.(png|jpe?g|gif)/,
type: 'asset/resource',
generator: {
filename: 'images/[name][ext]',
},
use: [
{
loader: 'image-webpack-loader',
},
],
},
{
test: /\.pug/,
use: [
{
loader: 'html-loader',
},
{
loader: 'pug-html-loader',
options: {
pretty: true,
},
},
]
}
],
},
target: ['web', 'es5'],
plugins: [
new MiniCssExtractPlugin({
filename: 'css/main.css',
}),
new HtmlWebpackPlugin({
template: './src/templates/index.pug',
filename: 'index.html',
}),
new CleanWebpackPlugin(),
new webpack.HotModuleReplacementPlugin(),
],
}
まず、ちゃんとコンパイルされるのかテストしたいので
各ファイルに最低限必要なコードを書いていきます。
doctype html
html(lang="ja")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title ビットくん
body
canvas#canvas
import '../css/main.scss';
import bitkunModel from '../models/bitkun.gltf';
console.log('Hello, world!')
* {
margin: 0;
padding: 0;
}
Blenderから書き出した3Dデータはmodelsフォルダに配置します。
コンパイルの準備はできました!
試しに書き出してみます。
npx webpack
成功すると、以下のようなファイルがdistディレクトリに書き出されます。
dist
├── css
│ └── main.css
├── index.html
├── js
│ └── main.js
└── models
└── bitkun.glb
シンプルですが必要なものが揃っています。
いい感じ!
以下はお好みですが、package.jsonにscriptsを追加しておきます。
"scripts": {
"start": "webpack serve --mode=development",
"prod": "webpack --mode=production",
"dev": "webpack"
}
開発中コマンド(ファイル変更の監視と書き出し)
ローカルサーバー(http://localhost:9000/)が起動します
npm start
開発用ビルド(非圧縮でファイル書き出し)
npm run dev
本番用ビルド(HTML・CSS・JSを圧縮してファイル書き出し)
npm run prod
準備完了です!
ちょっと長かったですが、環境は1度しっかり作っておけば理解も深まりますし、開発中は丁寧に作った恩恵を常に受けられるので時間が多少かかってでも納得のいく作りにするようにしています
Three.jsで3Dモデルを表示する
では、本題です
今回つくるものはこんな感じ
ビットくんのモデルを沢山クローンして立体空間に散りばめます
ブラウザ上で3Dグラフィックを扱うためのJSライブラリ「Three.js」を使います。Blenderを使わなくても、いろんな形がThree.js内には用意されているので、gltfデータを用意できない人はライブラリ内のジオメトリを使ってもいいかもしれません
まず、index.jsの全体のコードを書きます。続けて、ポイントを解説します
import '../css/main.scss';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader';
import bitkunModel from '../models/bitkun.gltf';
import helvetikerFontBold from 'three/examples/fonts/helvetiker_bold.typeface.json';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry';
// Canvas
const canvasEl = document.getElementById('canvas');
let scene, camera, renderer, bitkun;
let sizes = {
width: window.innerWidth,
height: window.innerHeight,
}
const bitkunArray = [];
const bitkunParams = {
numberOfBitkun: 500,
}
// Event Listeners
window.addEventListener('load', initScene);
window.addEventListener('resize', onWindowResize);
/**
* シーンの初期化
* @returns {void}
*/
function initScene() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x00aad9);
scene.fog = new THREE.Fog(0x00aad9, 10, 100);
// Camera
camera = new THREE.PerspectiveCamera(45, sizes.width / sizes.height, 0.1, 1000);
camera.position.set(0, 0, 0).multiplyScalar(7);
// Light
// ambient light
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// directional light
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-5, 10, 10);
scene.add(directionalLight);
// Renderer
renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvasEl,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Controls
let controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 3D Text
textFunc('Bit', 0, 2, 0);
textFunc('Beans', 0, -3, 5);
// Bitkun
createBitkunMesh(function () {
for (let i = 0; i < bitkunParams.numberOfBitkun; i++) {
bitkunArray.push(createBitkunClone());
}
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
render();
});
animate();
}
/**
* ビットくんのモデルを読み込む
* @param {function} callback
* @returns {void}
*/
function createBitkunMesh(callback) {
const loader = new GLTFLoader();
loader.load(
bitkunModel,
function(gltf) {
bitkun = gltf.scene;
bitkun.scale.set(20, 20, 20);
if(typeof callback === 'function') {
callback();
}
},
function(xhr) {
console.log((xhr.loaded / xhr.total) * 100 + '% loaded');
},
function(error) {
console.log('An error happened : ' + error);
}
)
}
/**
* ビットくんのクローンを作成する
* @returns {THREE.Mesh} - ビットくんのクローン
*/
function createBitkunClone() {
if(bitkun) {
const mesh = bitkun.clone();
mesh.children.map((child) => {
if(child.name === '円柱' || child.name === '円柱003') {
child.material = new THREE.MeshToonMaterial({
color: 0xffffff,
});
}else {
child.material = new THREE.MeshToonMaterial({
color: 0x00aad9,
});
}
})
mesh.position.x = Math.random() * 40 - 20;
mesh.position.y = Math.random() * 40 - 20;
mesh.position.z = Math.random() * 40 - 20;
mesh.rotation.x = Math.random() * Math.PI;
mesh.rotation.y = Math.random() * Math.PI;
mesh.rotation.z = Math.random() * Math.PI;
let scale = Math.random() * 0.15;
mesh.scale.set(scale, scale, scale);
scene.add(mesh);
return mesh
}
}
/**
* ウィンドウのリサイズ時
* @returns {void}
*/
function onWindowResize() {
// Update sizes
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
/**
* アニメーション
* @returns {void}
*/
let deg = -60;
let fps = 100;
function animate() {
setTimeout(() => {
requestAnimationFrame(animate);
deg += 0.01;
camera.position.x = 20 * Math.sin((deg * Math.PI) / 180);
camera.position.z = 20 * Math.cos((deg * Math.PI) / 180);
// 原点方向を見つめる
camera.lookAt(new THREE.Vector3(0, 0, 0));
bitkunArray.forEach((bitkun, index) => {
bitkun.rotation.x = performance.now() / 10000 + index;
bitkun.rotation.y = performance.now() / 10000 + index;
bitkun.rotation.z = performance.now() / 10000 + index;
});
render();
}, 1000 / fps);
}
/**
* レンダリング
* @returns {void}
*/
function render() {
renderer.render(scene, camera);
}
/**
* テキストを作成する
* @param {string} text - テキスト
* @param {number} positionX - X座標
* @param {number} positionY - Y座標
* @param {number} positionZ - Z座標
* @returns {void}
*/
function textFunc(text, positionX, positionY, positionZ) {
const fontLoader = new FontLoader().parse(helvetikerFontBold);
// TextGeometry
const textGeometry = new TextGeometry(text, {
font: fontLoader,
size: 5,
height: 1,
curveSegments: 13,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 5,
});
textGeometry.center();
// Material
const textMaterial = new THREE.MeshToonMaterial({
color: 0xffffff,
});
// Mesh
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.set(positionX, positionY, positionZ);
textMesh.rotation.y = - Math.PI * 0.25;
scene.add(textMesh);
}
とりあえず、まるっとindex.jsにコピーしてみましょう
ブラウザに大量のビットくんが表示されたでしょうか
それではポイントを解説していきます
1. 必要なライブラリ、変数の用意
import '../css/main.scss';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader';
import bitkunModel from '../models/bitkun.gltf';
import helvetikerFontBold from 'three/examples/fonts/helvetiker_bold.typeface.json';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry';
// Canvas
const canvasEl = document.getElementById('canvas');
let scene, camera, renderer, bitkun;
let sizes = {
width: window.innerWidth,
height: window.innerHeight,
}
const bitkunArray = [];
const bitkunParams = {
numberOfBitkun: 500,
}
// Event Listeners
window.addEventListener('load', initScene);
window.addEventListener('resize', onWindowResize);
まずは必要なライブラリをインポートしています。
- three … Three.js本体
- OrbitControls … Three.jsライブラリの一部です。マウス操作でカメラ視点を変更したり、スクロールでズームを調整できるので実装中は入れておいた方が便利です
- GLTFLoader … これもThree.jsの一部ですが、3Dモデルを読み込むためのツールです
そして、HTMLのcanvas要素を取得しています。ここにThree.jsで書くシーンやカメラ、ライト、など様々な要素を追加していきます。もちろん今回使うビットくんのモデルもここに追加していきます
Blenderで作成した3Dモデルのデータをはじめ、フォントのデータもここで取得しておきましょう、まとめておくとデータを差し替えたりする時便利です
今回は読み込んだビットくんを沢山クローンして表示したいので、それらのデータを格納する配列「bitkunArray」と、ビットくんの数を調整するための連想配列「bitkunParams」を用意しています
bitkunParamsにはnumberOfBitkunしかないので変数に入れてもいいのですが、どんな動きにするのか、どんな並びにするのか、コントロールしやすいように連想配列でまとめているとパターンを作りやすくなります、先を見越して連想配列に格納しています
最後にイベントリスナーを用意して、初期化時とブラウザリサイズ時に発火するようにしています
2. シーンの初期化
/**
* シーンの初期化
* @returns {void}
*/
function initScene() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x00aad9);
scene.fog = new THREE.Fog(0x00aad9, 10, 100);
// Camera
camera = new THREE.PerspectiveCamera(45, sizes.width / sizes.height, 0.1, 1000);
camera.position.set(0, 0, 0).multiplyScalar(7);
// Light
// ambient light
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
// directional light
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(-5, 10, 10);
scene.add(directionalLight);
// Renderer
renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvasEl,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Controls
let controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 3D Text
textFunc('Bit', 0, 2, 0);
textFunc('Beans', 0, -3, 5);
// Bitkun
createBitkunMesh(function () {
for (let i = 0; i < bitkunParams.numberOfBitkun; i++) {
bitkunArray.push(createBitkunClone());
}
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
render();
});
animate();
}
大まかには以下のような内容です
- シーンを作成し、背景色を設定
- カメラを作成し、位置を設定
- ライトを作成し、シーンに追加
- レンダラーを作成し、キャンバスに追加
- OrbitControlsを作成し、カメラとレンダラーを設定
- テキストを作成し、シーンに追加
- ビットくんのモデルを読み込み、クローンを作成してシーンに追加
- レンダリングを開始
といった感じです。1〜5まではThree.jsを使う上では必須で使いまわせるものがほとんどです。
6以降は見せたい演出によって工夫していくところになります。
3. Blenderデータの読み込み
/**
* ビットくんのモデルを読み込む
* @param {function} callback
* @returns {void}
*/
function createBitkunMesh(callback) {
const loader = new GLTFLoader();
loader.load(
bitkunModel,
function(gltf) {
bitkun = gltf.scene;
bitkun.scale.set(20, 20, 20);
if(typeof callback === 'function') {
callback();
}
},
function(xhr) {
console.log((xhr.loaded / xhr.total) * 100 + '% loaded');
},
function(error) {
console.log('An error happened : ' + error);
}
)
}
GLTFLoaderをつかって、あらかじめインポートしていたビットくんの3Dデータを読み込みます。3Dデータの読み込みを待って関数が実行されるようにcallback関数を使用し、bitkun変数にオブジェクトデータを格納します
ちょっと本題からずれますが、GLTFLoaderのloadメソッドにはデータ読み込みのプログレスやエラーを返す関数も用意されているようです。VSCodeでマウスカーソルを合わせるだけでメソッドの引数に何を持っているのか読めるの便利ですね。
4. 3Dモデルの複製
/**
* ビットくんのクローンを作成する
* @returns {THREE.Mesh} - ビットくんのクローン
*/
function createBitkunClone() {
if(bitkun) {
const mesh = bitkun.clone();
mesh.children.map((child) => {
if(child.name === '円柱' || child.name === '円柱003') {
child.material = new THREE.MeshToonMaterial({
color: 0xffffff,
});
}else {
child.material = new THREE.MeshToonMaterial({
color: 0x00aad9,
});
}
})
mesh.position.x = Math.random() * 40 - 20;
mesh.position.y = Math.random() * 40 - 20;
mesh.position.z = Math.random() * 40 - 20;
mesh.rotation.x = Math.random() * Math.PI;
mesh.rotation.y = Math.random() * Math.PI;
mesh.rotation.z = Math.random() * Math.PI;
let scale = Math.random() * 0.15;
mesh.scale.set(scale, scale, scale);
scene.add(mesh);
return mesh
}
}
bitkun変数に格納されたデータを量産します。なぜかGLTFデータを読み込んだ時に目のところだけ真っ黒になってしまって不本意なのでここで調整しています
Blenderで作ったものを書き出した時にちょっと印象が違う、微調整したい、という時もデータの上書きは可能でした、なんでもできますね。ランダムな数値を使って画面全体にbitkunを配置して、大きさもある程度ランダムにしました
5. テキストを3Dで表示
青い空間内に青いビットくんがいて、大きさもランダム、配置もランダムにすると遠近感がよくわかりません。そこで中央にテキストを配置してみました。
/**
* テキストを作成する
* @param {string} text - テキスト
* @param {number} positionX - X座標
* @param {number} positionY - Y座標
* @param {number} positionZ - Z座標
* @returns {void}
*/
function textFunc(text, positionX, positionY, positionZ) {
const fontLoader = new FontLoader().parse(helvetikerFontBold);
// TextGeometry
const textGeometry = new TextGeometry(text, {
font: fontLoader,
size: 5,
height: 1,
curveSegments: 13,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 5,
});
textGeometry.center();
// Material
const textMaterial = new THREE.MeshToonMaterial({
color: 0xffffff,
});
// Mesh
const textMesh = new THREE.Mesh(textGeometry, textMaterial);
textMesh.position.set(positionX, positionY, positionZ);
textMesh.rotation.y = - Math.PI * 0.25;
scene.add(textMesh);
}
テキストを3Dにしてシーンに追加する関数です、引数に表示したいテキストと表示位置を持たせてデザイン調整しやすくしています
他にも、演出面で変更を加えたい箇所は引数を持たせておくと柔軟性が生まれてくるので、変化を持たせたい部分や調整したい値は意識しておいた方が吉です
6. 動きをつける
/**
* アニメーション
* @returns {void}
*/
let deg = -60;
let fps = 100;
function animate() {
setTimeout(() => {
requestAnimationFrame(animate);
deg += 0.01;
camera.position.x = 20 * Math.sin((deg * Math.PI) / 180);
camera.position.z = 20 * Math.cos((deg * Math.PI) / 180);
// 原点方向を見つめる
camera.lookAt(new THREE.Vector3(0, 0, 0));
bitkunArray.forEach((bitkun, index) => {
bitkun.rotation.x = performance.now() / 10000 + index;
bitkun.rotation.y = performance.now() / 10000 + index;
bitkun.rotation.z = performance.now() / 10000 + index;
});
render();
}, 1000 / fps);
}
/**
* レンダリング
* @returns {void}
*/
function render() {
renderer.render(scene, camera);
}
動きをつけます、一番楽しいところですね
いろいろ試してみたくなります
requestAnimationFrame関数はブラウザのフレームレートに合わせて実行される関数ですが、処理の内容や環境によってはあえてfpsをおさえたい時もあります
setTimeout関数を使ってFPSを調整できるようにし、分岐もできるように変数に格納して外に出しています
7. レスポンシブ対応
/**
* ウィンドウのリサイズ時
* @returns {void}
*/
function onWindowResize() {
// Update sizes
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// Update camera
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// Update renderer
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
}
3Dモデルをレスポンシブに対応させるのはなかなか骨が折れる作業です。今回は基本的なリサイズする要素を入れ込んでいます
おまけ:ビットくん、何体だせるかな?
最後にパフォーマンスについて確認してみます
Three.jsのアドオンにパフォーマンスをモニターするツール「stats.js」があるので使ってみましょう
import Stats from 'three/examples/jsm/libs/stats.module.js';
/**
* レンダリング
* @returns {void}
*/
function render() {
stats.update(); // 追記
renderer.render(scene, camera);
}
//-- ここから追記
// Stats
const stats = new Stats();
stats.showPanel(0);
document.body.appendChild(stats.dom);
//-- ここまで追記
プログラムの先頭でstats.jsをimportして上記コードのように情報ボックスを埋め込んでみます
すると、画面左上に情報エリアが表示されました
詳しい内容についてはstats.jsのreadmeを確認して欲しいですが、FPSに関して言えば60FPSでていれば滑らかにアニメーションしていると言えるでしょう
試してみます
まずは50体
全然いけます、どんどん行きましょう
次は500体
意外といけます、思いっきり増やしてみましょう
1500体
49FPSまで落ちました
キービジュアルだと気になるレベルですが、背景など目立たないところでは使えそうです
倍の3000体にしてみます
かなりカクカクになりました
Three.jsはWebGLを比較的簡単に使用するためのライブラリなので、大量のオブジェクトを動かすためには工夫が必要です
どうすればもっと沢山のビットくんをアニメーションさせられるのか、、それはまた次回にしたいと思います
まとめ
お疲れ様でした!
ちょっと長かったですが、Three.jsは比較的手軽に3DとWebGLの内容に触れることができるライブラリです。長々と書いたコードも「必須のもの」と「表現によって工夫するもの」の2つに大きく分類できるので、要領が掴めてくるとじっくり読み込むべき箇所が浮き彫りになってきます
あと、「表現によって工夫するところ」も、狙った通りの表現・演出になった時の喜びはひとしおだと感じます
とにかく遊びつつ楽しみながら試してみることが理解への一歩になるのかなと思っています
記事がいいねと思ったら\いいね/してね
ガジェット小僧(エンジニア)
Takashi Yamaguchi
最近パパになるも「ガジェット小僧」の異名を持つ美術系大学出身のエンジニア。ランチはなるべく近場で済ませたい。2ブロック離れるともう不安。
Xもやってます!