1000本突破
1000本突破

快適なThree.jsの開発環境を手に入れたい!webpackで環境構築してみた。

くりちゃん

こんにちは、くりちゃんです。

Three.jsはwebglを簡単に使えるようにするライブラリですが、それでもメッシュを作ったり、シェーダーを書いたりするためには準備しなければいけないものが多いです。

まずThreejsを読み込んでカメラとレンダラーとシーンを用意してステージを作ったら、ジオメトリーとマテリアルからメッシュを作成してシーンに追加して、リクエストアニメーションフレームでループを回して、やっとメッシュを描画できた!

だけど開発中はOrbitControlsでカメラをぐりぐりできるようにしたいし、dat.guiで数値もぐりぐり動かしたい。シェーダーは別ファイルから読み込めるようして可読性を担保したいし、アニメーションにGSAPは必須な気がする。念のためFPSも計測しときたいのとcssとjsのビルドもしてリントも通したい etc.

普段は会社の開発環境にThree.jsを読み込んで、メッシュを描画するまでの汎用的な処理をコピペして持ってきて、ライブラリなどは都度 npm install することが多かったのですが、ぶっちゃけめんどくさいです。

なので、今回快適なThreejsの開発環境を手に入れるために、webpackを使って環境構築をしてみました。

完成したものがこちら。
https://github.com/hisamikurita/webpack-creativesite-boilerplate

npm run dev をすると下記のような画面でローカルホストが立ち上がり、汎用的なThreejsの処理やライブラリが読み込んであるのですぐにメッシュを作成したり、シェーダーを書くことができるようにしてみました。

フォルダ構造

今回は、このようなフォルダ構成にしました。

ビルドされたJavaScriptはwebpackのsplitChunksという機能を使用して、「複数のエントリーポイント間で利用している共通モジュールをバンドルしたファイル」をvendor.jsとして出力するように、webpack.config.jsで設定しています。この機能とても便利ですね!

optimization: {
    splitChunks: {
        name: 'vendor',
        chunks: 'initial'
    }
},

テンプレートの機能

webpackで行っているタスクとしては主に以下になります。

  • css/JavaScriptのビルド
  • css/JavaScriptの構文チェック
  • 画像の圧縮/webp変換
  • ローカルホストの立ち上げ

それに加えて、汎用的なThreejsの処理やライブラリを読み込みました。

stgae.jsではカメラやレンダラー、シーンの設定、sphere.jsではメッシュの設定を既に記述してあります。ここら辺、毎回コピペで持ってきてた汎用的な処理なのでこういうのあるだけでもだいぶ楽です。

import { Scene, PerspectiveCamera, WebGLRenderer, Vector3, GridHelper, AxesHelper, Color } from 'three'
import Stats from 'stats-js';
import OrbitControls from "three-orbitcontrols";

export default class Stage {
  constructor() {
    this.renderParam = {
      clearColor: 0x000000,
      width: window.innerWidth,
      height: window.innerHeight
    };
    this.cameraParam = {
      fov: 45,
      near: 0.1,
      far: 100,
      lookAt: new THREE.Vector3(0, 0, 0),
      x: 0,
      y: 0,
      z: 1.0,
    };

    this.scene = null;
    this.camera = null;
    this.renderer = null;
    this.isInitialized = false;
    this.orbitcontrols = null;
    this.stats = null;
    this.isDev = false;
  }

  init() {
    this._setScene();
    this._setRender();
    this._setCamera();
    this._setDev();
  }

  _setScene() {
    this.scene = new THREE.Scene();
  }

  _setRender() {
    this.renderer = new THREE.WebGLRenderer();
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setClearColor(new THREE.Color(this.renderParam.clearColor));
    this.renderer.setSize(this.renderParam.width, this.renderParam.height);
    const wrapper = document.querySelector("#webgl");
    wrapper.appendChild(this.renderer.domElement);
  }

  _setCamera() {
    if (!this.isInitialized) {
      this.camera = new THREE.PerspectiveCamera(
        0,
        0,
        this.cameraParam.near,
        this.cameraParam.far
      );

      this.camera.position.set(
        this.cameraParam.x,
        this.cameraParam.y,
        this.cameraParam.z
      );
      this.camera.lookAt(this.cameraParam.lookAt);

      this.isInitialized = true;
    }

    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;
    this.camera.aspect = windowWidth / windowHeight;
    this.camera.fov = this.cameraParam.fov;

    this.camera.updateProjectionMatrix();
    this.renderer.setSize(windowWidth, windowHeight);
  }

  _setDev() {
    this.scene.add(new THREE.GridHelper(1000, 100));
    this.scene.add(new THREE.AxesHelper(100));
    this.orbitcontrols = new OrbitControls(
      this.camera,
      this.renderer.domElement,
    );
    this.orbitcontrols.enableDamping = true;
    this.stats = new Stats();
    this.stats.domElement.style.position = "absolute";
    this.stats.domElement.style.left = "0px";
    this.stats.domElement.style.right = "0px";
    document.getElementById("stats").appendChild(this.stats.domElement);
    this.isDev = true;
  }

  _render() {
    this.renderer.render(this.scene, this.camera);
    if (this.isDev) this.stats.update();
    if (this.isDev) this.orbitcontrols.update();
  }

  onResize() {
    this._setCamera();
  }

  onRaf() {
    this._render();
  }
}
import { SphereBufferGeometry, RawShaderMaterial, Mesh, Color } from 'three'
import { gsap } from 'gsap';
import * as dat from 'dat.gui';
import vertexShader from '../shaders/vertexshader.vert';
import fragmentShader from '../shaders/fragmentshader.frag';

export default class Mesh {
  constructor(stage) {
    this.color = '#fff';
    this.stage = stage;
  }

  init() {
    this._setMesh();
    this._setDev();
  }

  _setMesh() {
    const geometry = new THREE.SphereBufferGeometry(0.20, 32, 32);
    const material = new THREE.RawShaderMaterial({
      vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      uniforms: {
        u_scale: { type: "f", value: 1.5 },
        u_color: { type: "v3", value: new THREE.Color(this.color) },
      },
    });
    this.mesh = new THREE.Mesh(geometry, material);
    this.stage.scene.add(this.mesh);

    gsap.to(this.mesh.material.uniforms.u_scale, {
      duration: 1.0,
      ease: 'none',
      value: 1.0,
    })
  }

  _setDev() {
    const parameter = {
      color: this.color,
    };
    const gui = new dat.GUI();
    gui.addColor(parameter, "color")
      .name("color")
      .onChange((value) => {
        this.mesh.material.uniforms.u_color.value = new THREE.Color(value);
      });
  }

  _render() {
    //
  }

  onResize() {
    //
  }

  onRaf() {
    this._render();
  }
}

webpackの設定ファイル

const path = require('path');
const dotenv = require('dotenv');
const BrowserSyncPlugin = require('browser-sync-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const StylelintPlugin = require('stylelint-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
const CopyPlugin = require("copy-webpack-plugin");
const ImageminWebpWebpackPlugin = require('imagemin-webp-webpack-plugin')
const { optimizeImage } = require('./.squooshrc');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
dotenv.config();

const mode = process.env.NODE_ENV;
const srcRelativePath = process.env.WEBPACK_SRC_RELATIVE_PATH || 'src';
const distRelativePath = process.env.WEBPACK_DIST_RELATIVE_PATH || 'dist';

const config = {
    mode: mode,
    entry: {
        app: [
            path.resolve(__dirname, `${srcRelativePath}/assets/scripts/app.js`),
            path.resolve(__dirname, `${srcRelativePath}/assets/stylesheets/app.scss`),
        ]
    },
    optimization: {
        splitChunks: {
            name: 'vendor',
            chunks: 'initial'
        }
    },
    output: {
        path: path.resolve(__dirname, `${distRelativePath}/assets`),
        filename: 'scripts/[name].js'
    },
    module: {
        rules: [
            {
                test: [/\.js$/],
                exclude: /node_modules/,
                use: [
                    {
                        loader: 'babel-loader',
                        options: {
                            cacheDirectory: true,
                            presets: [
                                [
                                    '@babel/preset-env',
                                    {
                                        "modules": false
                                    }
                                ]
                            ],
                        }
                    }
                ]
            },
            {
                test: [/\.scss$/, /\.css$/],
                use: [
                    {
                        loader: MiniCssExtractPlugin.loader,
                    },
                    {
                        loader: 'css-loader',
                    },
                    {
                        loader: "postcss-loader",
                        options: {
                            postcssOptions: {
                                plugins: [
                                    ["autoprefixer", { grid: true }],
                                ],
                            },
                        },
                    },
                    {
                        loader: 'sass-loader'
                    },
                ]
            },
            {
                test: [/\.(glsl|vs|fs|vert|frag)$/],
                exclude: /node_modules/,
                use: [
                    'raw-loader', 'glslify-loader'
                ]
            }
        ]
    },
    target: 'web',
    plugins: [
        new BrowserSyncPlugin({
            host: process.env.WEBPACK_BROWSER_SYNC_HOST || 'localhost',
            port: process.env.WEBPACK_BROWSER_SYNC_PORT || 3000,
            proxy: process.env.WEBPACK_BROWSER_SYNC_PROXY || false,
            server: process.env.WEBPACK_BROWSER_SYNC_PROXY ? false : distRelativePath,
            open: false,
            files: [distRelativePath],
            injectChanges: true,
        }),
        new CleanWebpackPlugin,
        new StylelintPlugin({ configFile: path.resolve(__dirname, '.stylelintrc.js') }),
        new ESLintPlugin({
            extensions: ['.js'],
            exclude: 'node_modules'
        }),
        new CopyPlugin({
            patterns: [
                {
                    from: path.resolve(__dirname, `${srcRelativePath}/assets/images`),
                    to: 'images/[name][ext]',
                    noErrorOnMissing: true,
                    transform: {
                        transformer: mode === 'production' ? optimizeImage : content => content
                    }
                }
            ]
        }),
        new ImageminWebpWebpackPlugin({
            config: [{
                test: /\.(jpe?g|png)$/i,
                options: {
                    quality: 60
                },
            }],
        }),
        new MiniCssExtractPlugin({
            filename: 'stylesheets/[name].css'
        }),
    ],
}

if (mode === 'development') {
    config.devtool = 'source-map';
}

module.exports = config;

使い方

  1. リポジトリをクローンします
  2. Node.jsをインストールしてない場合はNode.jsをインストールしてください。(v14.0.0以上推奨です)
  3. .envファイルを作成して、.env-sampleの内容をコピーして貼り付けます
  4. ターミナルでnpm install npm run devを叩くとローカル環境が立ち上がります
  5. 本番環境へアップする際はnpm run prodを叩くとコードが圧縮されて吐き出されます

まとめ

実際の案件で使うにはまだ改善する点が多くありますが、サクッとThreejsを書きたいときはこのような汎用的な処理やライブラリが既に読み込んであると快適にコーディングができますね。

余談ですが、どうしても開発環境の話になると、そんなことよりコードを書きたい! と思ってしまい後回しにしてしまいがちですよね。

LIGに入社するの前の制作会社では各々が開発環境を用意して制作をしていました。それからLIGに入社して、LIGでは開発環境がある程度整備されていて、会社の開発環境を使えば、一定ラインのクオリティは担保できます。

これってとても凄いことですよね。それから開発環境が制作のクオリティに与える影響は大きいのだと気付かされました。

今回作った開発環境もまだまだ発展途上です。Threejsは比較的容量の大きいライブラリで、それをひとまとまりにバンドルすると500KB~以上になってしまいます。

このあたりをCode Splittingで複数ファイルに分割することで、1ファイルを取得するまでのレスポンス時間を短くできれば、パフォーマンス向上につながると思うので、今後試していければ良いなと思います! では!